Quickstart
Embarking on your journey with ts-rest
is a breeze! You have the liberty to utilize just the front-end, the backend, or merely the contract (should you choose to do so).
Once you've mastered the fundamentals, you'll find the developer experience to be smooth as silk. ✨
Installation
Install the core package and zod - Some of our generics rely on zod being installed, so make sure to install it (even as a dev dependency if you don't plan to use Zod)
If you don't install zod, some confusing errors may appear but it should mostly work #303
- pnpm
- npm
- yarn
pnpm add @ts-rest/core zod
npm install @ts-rest/core zod
yarn add @ts-rest/core zod
Create a contract
- Zod
- Basic
This should ideally be shared between your consumers and producers, e.g. in a shared library in a monorepo, or a shared npm package. Think of this as your HTTP Schema that both your client and backend can use.
strict
with Zod// contract.ts
import { initContract } from '@ts-rest/core';
import { z } from 'zod';
const c = initContract();
const PostSchema = z.object({
id: z.string(),
title: z.string(),
body: z.string(),
});
export const contract = c.router({
createPost: {
method: 'POST',
path: '/posts',
responses: {
201: PostSchema,
},
body: z.object({
title: z.string(),
body: z.string(),
}),
summary: 'Create a post',
},
getPost: {
method: 'GET',
path: `/posts/:id`,
responses: {
200: PostSchema.nullable(),
},
summary: 'Get a post by id',
},
});
Zod also has some nice features, like enabling body parsing and OpenAPI type generation.
// contract.ts
import { initContract } from '@ts-rest/core';
const c = initContract();
export const contract = c.router({
createPost: {
method: 'POST',
path: '/posts',
responses: {
201: c.type<Post>(),
},
body: c.type<{title: string}>(),
summary: 'Create a post',
},
getPost: {
method: 'GET',
path: `/posts/:id`,
responses: {
200: c.type<Post | null>(),
},
summary: 'Get a post by id',
},
});
Server Implementation
- Nest.js
- Express
- Fastify
- Next.js
- pnpm
- npm
- yarn
pnpm add @ts-rest/nest
npm install @ts-rest/nest
yarn add @ts-rest/nest
ts-rest offers a unique way to create a fully type safe REST API server, normally Nest APIs are extremely powerful, but hard to make type safe.
Let's add @ts-rest/nest
to a basic Nest controller:
// post.controller.ts
const c = nestControllerContract(contract);
type RequestShapes = NestRequestShapes<typeof c>;
@Controller()
export class PostController implements NestControllerInterface<typeof c> {
constructor(private readonly postService: PostService) {}
@TsRest(c.getPost)
async getPost(@TsRestRequest() { params: { id } }: RequestShapes['getPost']) {
const post = await this.postService.getPost(id);
return { status: 200 as const, body: post };
}
@TsRest(c.createPost)
async createPost(@TsRestRequest() { body }: RequestShapes['createPost']) {
const post = await this.postService.createPost({
title: body.title,
body: body.body,
});
return { status: 201 as const, body: post };
}
}
You can see that we're using the runtime object c
in the TsRest
decorator to automatically declare your path from the contract's getPost
route.
We're also using the RequestShapes
TypeScript Types (which comes from the runtime object c
) to ensure type safety of your contract on the Nest controller.
If you were to change the body
return type to { body: true }
for example, this will give you a typescript error: Your body
is defined as an object in the contract above, not boolean!
- pnpm
- npm
- yarn
pnpm add @ts-rest/express
npm install @ts-rest/express
yarn add @ts-rest/express
The express implementaton allows full type safety, offering; body parsing, query parsing, param parsing and full error handling
// main.ts
import { initServer } from '@ts-rest/express';
const app = express();
app.use(cors());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());
const s = initServer();
const router = s.router(contract, {
getPost: async ({ params: { id } }) => {
const post = await prisma.post.findUnique({ where: { id } });
return {
status: 200,
body: post,
};
},
createPost: async ({ body }) => {
const post = await prisma.post.create({
data: body,
});
return {
status: 201,
body: post,
};
},
});
createExpressEndpoints(contract, router, app);
const port = process.env.port || 3333;
const server = app.listen(port, () => {
console.log(`Listening at http://localhost:${port}`);
});
- pnpm
- npm
- yarn
pnpm add @ts-rest/fastify
npm install @ts-rest/fastify
yarn add @ts-rest/fastify
The fastify implementaton allows full type safety, offering; body parsing, query parsing, param parsing and full error handling
// main.ts
import { initServer } from '@ts-rest/fastify';
const app = fastify();
const s = initServer();
const router = s.router(contract, {
getPost: async ({ params: { id } }) => {
const post = await prisma.post.findUnique({ where: { id } });
return {
status: 200,
body: post,
};
},
createPost: async ({ body }) => {
const post = await prisma.post.create({
data: body,
});
return {
status: 201,
body: post,
};
},
});
app.register(s.plugin(router));
const start = async () => {
try {
await app.listen({ port: 3000 });
} catch (err) {
app.log.error(err);
process.exit(1);
}
};
start();
- pnpm
- npm
- yarn
pnpm add @ts-rest/next
npm install @ts-rest/next
yarn add @ts-rest/next
// pages/api/[...ts-rest].tsx
// `contract` is the AppRouter returned by `c.router`
const postsRouter = createNextRoute(contract.posts, {
createPost: async (args) => {
const newPost = await posts.createPost(args.body);
return {
status: 201,
body: newPost,
};
},
});
const router = createNextRoute(contract, {
posts: postsRouter,
});
// Actually initiate the collective endpoints
export default createNextRouter(contract, router);
Client Implementation
- Fetch
- React Query
This is the basic client, using fetch under the hood which is exported from @ts-rest/core
.
// client.ts
import { initClient } from "@ts-rest/core";
// `contract` is the AppRouter returned by `c.router`
const client = initClient(contract, {
baseUrl: 'http://localhost:3000',
baseHeaders: {},
});
const { body, status } = await client.createPost({
body: {
title: 'Post Title',
body: 'Post Body',
},
});
if (status === 201) {
// body is Post
console.log(body);
} else {
// body is unknown
console.log(body);
}
- pnpm
- npm
- yarn
pnpm add @ts-rest/react-query
npm install @ts-rest/react-query
yarn add @ts-rest/react-query
// client.ts
import { initQueryClient } from "@ts-rest/react-query";
// `contract` is the AppRouter returned by `c.router`
export const client = initQueryClient(contract, {
baseUrl: 'http://localhost:3333',
baseHeaders: {},
});
export const Index = () => {
const { data, isLoading, error } = client.getPost.useQuery(["posts/1"], {
params: { id: '1' },
});
if (isLoading) {
return <div>Loading...</div>;
}
if (data.status !== 200 || error) {
return <div>Error</div>;
}
return <div>{data.body.title}</div>;
};
The response from react-query is typed as follows:
If status is 2XX, it's part of the "data" return. If it's any other status code it's part of the "error" return e.g.
const data:
| {
status: 200;
body: Post | null;
}
| undefined;
const error:
| {
status: 404;
body: null
}
| {
status: 404 | 100 | 101 | 102 | 300 | 301 | 302 | 303 | 304 | 305 | 307 | 308 | 400 | 401 | 402 | 403 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 419 | ... 16 more ... | 511;
body: unknown;
}
| null