Zod 4 (and all other Standard Schema support) is available as a release candidate `3.53.0-rc.1`
ts-rest
Contract

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.

contract.ts

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.

contract.ts
import {  } from '@ts-rest/core';
import {  } from 'zod'; 

const  = ();

export const  = .({
  : {
    : 'GET',
    : '/pokemon',
    : .({ 
      : .().(), 
      : .().(), 
    }), 
    : {
      200: .({ 
        : .(), 
        : .(), 
      }), 
    },
    : 'Search for a pokemon',
  },
});
contract.ts
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',
  },
});
contract.ts
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',
  },
});
contract.ts
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!).

validated-path-params.ts
export const  = .({
  : {
    : 'PUT',
    : '/pokemon/:id',
    : , 
    : , 
    : { 
      'x-api-key': .(), 
    }, 
    : {
      200: , 
      400: , 
    },
  },
});

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.

path-params.ts
export const  = .({
  : {
    : 'GET',
    : '/pokemon/:id', 
    : {
      200: ,
    },
  },
});

type  = typeof ;

type ById = <['getPokemon']>['params'];
type ById = {
    id: string;
}

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.

validated-path-params.ts
export const  = .({
  : {
    : 'GET',
    : '/pokemon/type/:type', 
    : .({
      : .(['fire', 'water', 'grass', 'electric', 'psychic', 'normal']),
    }),
    : {
      200: ,
    },
  },
});

type  = typeof ;

type ByType = <['getPokemonByType']>['params'];
type ByType = {
    type: "fire" | "water" | "grass" | "electric" | "psychic" | "normal";
}

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.

transformed-path-params.ts

export const  = .({
  : {
    : 'GET',
    : '/pokemon/:id', 
    : .({
      : .().(() => ()),
    }),
    : {
      200: ,
    },
  },
});

type  = typeof ;

type Input = <['getPokemon']>['params'];
type Input = {
    id: string;
}
type Output = <['getPokemon']>['params'];
type Output = {
    id: number;
}

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.

query-params.ts
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 }>(),
    },
  },
});
query-params.ts
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 }>(),
    },
  },
});
query-params.ts
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 }>(),
    },
  },
});
query-params.ts
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.

json-query.ts
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 }>(),
    },
  },
});
json-query.ts
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 }>(),
    },
  },
});
json-query.ts
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.

headers.ts
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[] }>(),
    },
  },
});
headers.ts
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[] }>(),
    },
  },
});
headers.ts
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[] }>(),
    },
  },
});
headers.ts
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.

base-headers.ts

export const  = .(
  {
    : {
      : 'GET',
      : '/pokemon/:id',
      : {
        200: .<>(),
      },
      : { 
        'x-api-key': .(), 
      }, 
    },
    : {
      : 'PUT',
      : '/pokemon/:id',
      : .<>(),
      : {
        200: .<>(),
      },
      : { 
        'x-api-key': .(), 
      }, 
    },
  },
  {
    : { 
      'x-api-key': .(), 
    }, 
  },
);

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:

base-headers.ts
headers: {
  'x-api-key': null,  
},

Base headers are also inherited by nested contracts, so this is perfectly valid:

base-headers.ts

export const  = .(
  {
    : .(
      {
        : {
          : 'GET',
          : '/pokemon/:id',
          : {
            200: .<>(),
          },
          : {
            'x-inner': .(), 
          },
        },
      },
      {
        : {
          'x-sub-contract': .(), 
        },
      },
    ),
  },
  {
    : {
      'x-contact': .(), 
    },
  },
);

type Headers = <typeof ..>['headers'];
type Headers = {
    "x-inner": string;
    "x-sub-contract": string;
    "x-contact": string;
}

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.

responses.ts
export const  = .({
  : {
    : 'GET',
    : '/pokemon/csv',
    : {
      200: .({ 
        : .(), 
        : .(), 
        : .(), 
      }), 
    },
  },
});

Other Responses

To define a non application/json response, you can use c.otherResponse:

responses.ts

export const  = .({
  : {
    : 'GET',
    : '/pokemon/:id',
    : {
      200: .({ 
        : 'text/csv', 
        : .(), 
      }), 
    },
  },
});

No Body Response

Sometimes you just want to send a request with no body, or a response with no body.

responses.ts

export const  = .({
  : {
    : 'DELETE',
    : '/pokemon/:id',
    : .(),
    : {
      204: .(),
    },
  },
});

type Request = <typeof .>;
type Request = {
    cache?: RequestCache | undefined;
    fetchOptions?: FetchOptions | undefined;
    params: {
        id: string;
    };
    extraHeaders?: Record<string, string> | undefined;
    overrideClientOptions?: Partial<OverrideableClientArgs> | undefined;
}
type Response = <typeof ., 204>;
type Response = undefined

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.

common-responses.ts
export const  = .(
  {
    : {
      : 'GET',
      : '/pokemon/:id',
      : {
        200: .<>(),
      },
    },
  },
  {
    : {
      404: .<{ : string[]; : string }>(),
      500: .<{ : string }>(),
    },
  },
);

type Response = <typeof .>;
type Response = {
    status: 200;
    body: Pokemon;
    headers: Headers;
} | {
    status: 404;
    body: {
        issues: string[];
        message: string;
    };
    headers: Headers;
} | {
    status: 500;
    body: {
        message: string;
    };
    headers: Headers;
} | {
    ...;
}

Combining Contracts

Combine contracts to create modular, organized APIs. This is especially helpful for large applications where you want to split contracts by domain.

contract.ts
user-contract.ts
post-contract.ts
contract.ts
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.

metadata.ts
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.

server.ts

const metadata = ..;
const metadata: {
    readonly role: "trainer";
}

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.

strict-status-codes.ts
export const  = .(
  {
    : {
      : 'GET',
      : '/pokemon/:id',
      : {
        200: .<>(),
        404: .<{ : string }>(),
      },
      : true, 
    },
  },
  {
    : 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.

path-prefix.ts
const  = .(
  {
    : {
      : 'GET',
      : '/pokemon/:id',
      : {
        200: .<>(),
      },
    },
  },
  {
    : '/v1', 
  },
);

// Nested contracts combine prefixes
const  = .(
  {
    : ,
  },
  {
    : '/api', 
  },
);

const fullPath = ...;
const fullPath: "/api/v1/pokemon/:id"

Type Hints and Intellisense

For better developer experience, you can add JSDoc comments to provide intellisense on your contract types.

intellisense.ts
export const  = .({
  : {
    : 'GET',
    : '/pokemon',
    : {
      200: .<[]>(),
    },
    : .({
      /**
       * @description The type of pokemon
       */
      : 
        .(['fire', 'water', 'grass', 'electric', 'psychic', 'normal'])
        .(),
      /**
       * @description Filter pokemon by name
       */
      : .().(),
    }),
    : 'Search for a pokemon',
  },
});


await .({
  : {
    search: 'pikachu',
search?: string | undefined
@descriptionFilter pokemon by name
: '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