Overview
The contract is the core of ts-rest, it defines the API contract between your server and client.
The contract is the core of ts-rest - it defines the API specification that both your server and client adhere to. This ensures type-safety across your entire stack while keeping your API close to standard HTTP/REST principles.
Contract Location
Many tweams who use ts-rest
tent to put their contract in a shared place, whether that's a shared module in a monorepo, a shared folder in a single repo or a external package.
Validation Libraries
You can define your contract fields such as body
, query
, pathParams
, and headers
using either validation libraries or plain TypeScript types.
Note: We support any validation library which implements the Standard Schema interface, the main ones are: Zod, Valibot, Arktype and Effect Schema.
import { initContract } from '@ts-rest/core';
import { z } from 'zod';
const c = initContract();
export const contract = c.router({
searchPokemon: {
method: 'GET',
path: '/pokemon',
query: z.object({
name: z.string().optional(),
type: z.string().optional(),
}),
responses: {
200: z.object({
name: z.string(),
type: z.string(),
}),
},
summary: 'Search for a pokemon',
},
});
import { initContract } from '@ts-rest/core';
import * as v from 'valibot';
const c = initContract();
export const contract = c.router({
searchPokemon: {
method: 'GET',
path: '/pokemon',
query: v.object({
name: v.string().optional(),
type: v.string().optional(),
}),
responses: {
200: v.object({
name: v.string(),
type: v.string(),
}),
},
summary: 'Search for a pokemon',
},
});
import { initContract } from '@ts-rest/core';
import { type } from 'arktype';
const c = initContract();
export const contract = c.router({
searchPokemon: {
method: 'GET',
path: '/pokemon',
query: type({
name: 'string',
type: 'string',
}),
responses: {
200: type({
name: 'string',
type: 'string',
}),
},
summary: 'Search for a pokemon',
},
});
import { initContract } from '@ts-rest/core';
const c = initContract();
type Pokemon = {
id: string;
name: string;
type: string;
};
export const contract = c.router({
searchPokemon: {
method: 'GET',
path: '/pokemon',
responses: {
200: c.type<Pokemon>(),
},
body: c.type<{
name: string;
type: string;
}>(),
summary: 'Search for a pokemon',
},
});
What can be validated?
If you can send it, we can probably validate it - ts-rest
supports body
, query
, pathParams
and headers
validation,
you can also pass a schema for the response types, allowing for bidirectional validation (no more oversharing data from an overzealous service method!).
export const contract = c.router({
updatePokemon: {
method: 'PUT',
path: '/pokemon/:id',
pathParams: PathParamsSchema,
body: PokemonSchema,
headers: {
'x-api-key': z.string(),
},
responses: {
200: PokemonSchema,
400: ErrorSchema,
},
},
});
We'll cover more details about each possible validation type in the following sections.
Path Parameters
Define path parameters by adding them to the path
string with a colon :
followed by the parameter name. The path parameters will be correctly inferred and included in the params
object.
We automatically infer the path parameters from this string, and treat them as string
by default.
export const contract = c.router({
getPokemon: {
method: 'GET',
path: '/pokemon/:id',
responses: {
200: PokemonSchema,
},
},
});
type Contract = typeof contract;
type ById = ClientInferRequest<Contract['getPokemon']>['params'];
Note: The path
field should contain the full path on the server, not
just a sub-path of a route.
Validating and Parsing Path Parameters
If you need to run validations or transformations on path parameters, define a schema using the pathParams
field. The parameter names must match those in the path
string.
This allows you to do any form of validation or transformations you want.
export const contract = c.router({
getPokemonByType: {
method: 'GET',
path: '/pokemon/type/:type',
pathParams: z.object({
type: z.enum(['fire', 'water', 'grass', 'electric', 'psychic', 'normal']),
}),
responses: {
200: PokemonSchema,
},
},
});
type Contract = typeof contract;
type ByType = ClientInferRequest<Contract['getPokemonByType']>['params'];
Transforming Path Parameters
If you wish to accept a integer, you often find that path params are by default strings - you can use the pathParams
field to transform them to the type you want.
export const contract = c.router({
getPokemon: {
method: 'GET',
path: '/pokemon/:id',
pathParams: z.object({
id: z.string().transform((id) => Number(id)),
}),
responses: {
200: PokemonSchema,
},
},
});
type Contract = typeof contract;
type Input = ClientInferRequest<Contract['getPokemon']>['params'];
type Output = ServerInferRequest<Contract['getPokemon']>['params'];
Query Parameters
Query parameters are always strings in their raw form, so they must be typed as such unless you use transforms or coercions to convert them to other types.
import { initContract } from '@ts-rest/core';
import { z } from 'zod';
const c = initContract();
// ---cut---
export const contract = c.router({
getPokemon: {
method: 'GET',
path: '/pokemon/:id',
query: z.object({
take: z.coerce.number().default(10),
skip: z.coerce.number().default(0),
search: z.string().optional(),
published: z.enum(['true', 'false']).optional(),
}),
responses: {
200: c.type<{ posts: any[]; total: number }>(),
},
},
});
import { initContract } from '@ts-rest/core';
import * as v from 'valibot';
const c = initContract();
// ---cut---
export const contract = c.router({
getPokemon: {
method: 'GET',
path: '/pokemon/:id',
query: v.object({
take: v.pipe(v.string(), v.transform(Number), v.fallback(10)),
skip: v.pipe(v.string(), v.transform(Number), v.fallback(0)),
search: v.optional(v.string()),
published: v.optional(v.picklist(['true', 'false'])),
}),
responses: {
200: c.type<{ posts: any[]; total: number }>(),
},
},
});
import { initContract } from '@ts-rest/core';
import { type } from 'arktype';
const c = initContract();
export const contract = c.router({
getPokemon: {
method: 'GET',
path: '/pokemon/:id',
query: type({
'take?': 'string.numeric.parse',
'skip?': 'string.numeric.parse',
'search?': 'string',
'published?': "'true' | 'false'",
}),
responses: {
200: c.type<{ posts: any[]; total: number }>(),
},
},
});
import { initContract } from '@ts-rest/core';
const c = initContract();
export const contract = c.router({
getPokemon: {
method: 'GET',
path: '/pokemon/:id',
query: c.type<{
take?: string;
skip?: string;
search?: string;
published?: 'true' | 'false';
}>(),
responses: {
200: c.type<{ posts: any[]; total: number }>(),
},
},
});
JSON Query Parameters
You can configure ts-rest to encode/decode query parameters as JSON using the jsonQuery
option. This allows you to use complex typed objects without string coercions.
import { initContract } from '@ts-rest/core';
import { z } from 'zod';
const c = initContract();
export const contract = c.router({
getPokemon: {
method: 'GET',
path: '/pokemon/:id',
query: z.object({
take: z.number().default(10),
skip: z.number().default(0),
filter: z.object({
by: z.enum(['title', 'author', 'content']),
search: z.string(),
}).optional(),
}),
responses: {
200: c.type<{ posts: any[]; total: number }>(),
},
},
});
import { initContract } from '@ts-rest/core';
import * as v from 'valibot';
const c = initContract();
export const contract = c.router({
getPokemon: {
method: 'GET',
path: '/pokemon/:id',
query: v.object({
take: v.fallback(v.number(), 10),
skip: v.fallback(v.number(), 0),
filter: v.optional(v.object({
by: v.picklist(['title', 'author', 'content']),
search: v.string(),
})),
}),
responses: {
200: c.type<{ posts: any[]; total: number }>(),
},
},
});
import { initContract } from '@ts-rest/core';
import { type } from 'arktype';
const c = initContract();
export const contract = c.router({
getPokemon: {
method: 'GET',
path: '/pokemon/:id',
query: type({
take: 'number = 10',
skip: 'number = 0',
'filter?': {
by: "'title' | 'author' | 'content'",
search: 'string',
},
}),
responses: {
200: c.type<{ posts: any[]; total: number }>(),
},
},
});
Check the relevant client and server sections to see how to enable jsonQuery
in your implementation.
Headers
Define headers in your contract with a string input type. You can use transforms or coercion to convert string values to different types if needed.
import { initContract } from '@ts-rest/core';
import { z } from 'zod';
const c = initContract();
export const contract = c.router({
getPokemon: {
method: 'GET',
path: '/pokemon/:id',
headers: {
authorization: z.string(),
'x-pagination-limit': z.coerce.number().optional(),
'x-api-version': z.string().default('v1'),
},
responses: {
200: c.type<{ posts: any[] }>(),
},
},
});
import { initContract } from '@ts-rest/core';
import * as v from 'valibot';
const c = initContract();
export const contract = c.router({
getPokemon: {
method: 'GET',
path: '/pokemon/:id',
headers: {
authorization: v.string(),
'x-pagination-limit': v.optional(v.pipe(v.string(), v.transform(Number))),
'x-api-version': v.fallback(v.string(), 'v1'),
},
responses: {
200: c.type<{ posts: any[] }>(),
},
},
});
import { initContract } from '@ts-rest/core';
import { type } from 'arktype';
const c = initContract();
export const contract = c.router({
getPokemon: {
method: 'GET',
path: '/pokemon/:id',
headers: {
authorization: type('string'),
'x-pagination-limit': type('string.numeric.parse'),
'x-api-version': type('string = "v1"'),
},
responses: {
200: c.type<{ posts: any[] }>(),
},
},
});
import { initContract } from '@ts-rest/core';
const c = initContract();
export const contract = c.router({
getPokemon: {
method: 'GET',
path: '/pokemon/:id',
headers: {
authorization: c.type<string>(),
'x-pagination-limit': c.type<string>(),
'x-api-version': c.type<string>(),
},
responses: {
200: c.type<{ posts: any[] }>(),
},
},
});
Base Headers
You can define base headers for all routes in a contract, useful for things like authorization headers. This forces the client to always pass these headers unless also defined in the client's baseHeaders
.
export const contract = c.router(
{
getPokemon: {
method: 'GET',
path: '/pokemon/:id',
responses: {
200: c.type<Pokemon>(),
},
headers: {
'x-api-key': z.string(),
},
},
updatePokemon: {
method: 'PUT',
path: '/pokemon/:id',
body: c.type<Pokemon>(),
responses: {
200: c.type<Pokemon>(),
},
headers: {
'x-api-key': z.string(),
},
},
},
{
baseHeaders: {
'x-api-key': z.string(),
},
},
);
It's also possible to "nullify" a base header within a specific route by setting it to null
, for example if you're making one endpoint public, you can do:
headers: {
'x-api-key': null,
},
Base headers are also inherited by nested contracts, so this is perfectly valid:
export const contract = c.router(
{
pokemon: c.router(
{
getPokemon: {
method: 'GET',
path: '/pokemon/:id',
responses: {
200: c.type<Pokemon>(),
},
headers: {
'x-inner': z.string(),
},
},
},
{
baseHeaders: {
'x-sub-contract': z.string(),
},
},
),
},
{
baseHeaders: {
'x-contact': z.string(),
},
},
);
type Headers = ClientInferRequest<typeof contract.pokemon.getPokemon>['headers'];
Responses
Define response types as a map of status codes to response schemas. Responses default to application/json
content-type, but you can define other response types using c.otherResponse
.
Status Codes
We consider any 2XX status code to be a success, and any 4XX or 5XX status code to be an error - this is relevant to the client implementation, as for example, the react-query client splits up success/failure by data/error objects.
export const contract = c.router({
getPokemonCsv: {
method: 'GET',
path: '/pokemon/csv',
responses: {
200: z.object({
id: z.string(),
title: z.string(),
content: z.string(),
}),
},
},
});
Other Responses
To define a non application/json
response, you can use c.otherResponse
:
export const contract = c.router({
getPokemon: {
method: 'GET',
path: '/pokemon/:id',
responses: {
200: c.otherResponse({
contentType: 'text/csv',
body: z.string(),
}),
},
},
});
No Body Response
Sometimes you just want to send a request with no body, or a response with no body.
export const contract = c.router({
deletePokemon: {
method: 'DELETE',
path: '/pokemon/:id',
body: c.noBody(),
responses: {
204: c.noBody(),
},
},
});
type Request = ClientInferRequest<typeof contract.deletePokemon>;
type Response = ServerInferResponseBody<typeof contract.deletePokemon, 204>;
Common Responses
A common practise in API design is to define common responses across all routes in your contract, typically for error responses. We support this with the commonResponses
option.
export const contract = c.router(
{
getPokemon: {
method: 'GET',
path: '/pokemon/:id',
responses: {
200: c.type<Pokemon>(),
},
},
},
{
commonResponses: {
404: c.type<{ issues: string[]; message: string }>(),
500: c.type<{ message: string }>(),
},
},
);
type Response = ClientInferResponses<typeof contract.getPokemon>;
Combining Contracts
Combine contracts to create modular, organized APIs. This is especially helpful for large applications where you want to split contracts by domain.
import { postContract } from './post-contract';
import { userContract } from './user-contract';
export const contract = c.router({
posts: postContract,
users: userContract,
});
Metadata
Attach metadata to your contract routes that can be accessed throughout ts-rest. This is useful for things like role-based access control or OpenAPI documentation.
import { initContract } from '@ts-rest/core';
const c = initContract();
export const contract = c.router({
getPokemon: {
method: 'GET',
path: '/pokemon/:id',
responses: {
200: c.type<Pokemon>(),
},
summary: 'Get a pokemon',
metadata: { role: 'trainer' } as const,
},
updatePokemon: {
method: 'POST',
path: '/pokemon/:id',
body: c.type<Pokemon>(),
responses: {
200: c.type<Pokemon>(),
},
summary: 'Update a pokemon',
metadata: { role: 'satoshi' } as const,
},
});
Important: Since the contract is used on both server and client, metadata will be included in your client-side bundle. Never put sensitive information in metadata.
const metadata = contract.getPokemon.metadata;
Contract Options
Configure some extra behaviour with these contract options.
Strict Status Codes
By default, ts-rest allows any response status code. Enable strictStatusCodes
to only allow status codes defined in your contract.
export const contract = c.router(
{
getPokemon: {
method: 'GET',
path: '/pokemon/:id',
responses: {
200: c.type<Pokemon>(),
404: c.type<{ message: string }>(),
},
strictStatusCodes: true,
},
},
{
strictStatusCodes: true,
},
);
Important: When using strictStatusCodes
with the fetch client, you must
also enable throwOnUnknownStatus
in the client options to match runtime
behavior with TypeScript types.
Path Prefix
Add a prefix to all paths in your contract, useful for API versioning or modular routing.
const pokemonContract = c.router(
{
getPokemon: {
method: 'GET',
path: '/pokemon/:id',
responses: {
200: c.type<Pokemon>(),
},
},
},
{
pathPrefix: '/v1',
},
);
// Nested contracts combine prefixes
const apiContract = c.router(
{
pokemon: pokemonContract,
},
{
pathPrefix: '/api',
},
);
const fullPath = apiContract.pokemon.getPokemon.path;
Type Hints and Intellisense
For better developer experience, you can add JSDoc comments to provide intellisense on your contract types.
export const contract = c.router({
searchPokemon: {
method: 'GET',
path: '/pokemon',
responses: {
200: c.type<Pokemon[]>(),
},
query: z.object({
/**
* @description The type of pokemon
*/
type: z
.enum(['fire', 'water', 'grass', 'electric', 'psychic', 'normal'])
.optional(),
/**
* @description Filter pokemon by name
*/
search: z.string().optional(),
}),
summary: 'Search for a pokemon',
},
});
await client.searchPokemon({
query: {
search: 'pikachu', type: 'fire',
},
});
Ready to implement your contract?
🚀 Next Steps: - Server implementation → - Learn how to fulfill your contract on the server - Client usage → - Use your contract with the type-safe client - OpenAPI generation → - Generate OpenAPI specs from your contract