Declaration
Learn how to declare contracts with rest-rpc
A contract is a plain TypeScript object passed through router().
import { router } from "@rest-rpc/core";
import { z } from "zod";
const todoSchema = z.object({
id: z.string(),
title: z.string(),
completed: z.boolean(),
});
export const api = router({
todos: {
get: {
method: "GET",
path: "/todos/:id",
response: todoSchema,
},
},
});
The contract is used at runtime and at type level. It can drive server handlers, clients, TanStack Query options, and OpenAPI generation.
Route Fields
HTTP routes use these fields:
method:GET,POST,PUT,DELETE, orPATCH.path: HTTP path with static segments and full dynamic:paramor{param}segments.pathParams: path parameter schemas.query: query parameter schemas.headers: request header schemas.body: request body schema.response: one successful response body, with status inferred frommethod.responses: status-keyed response body declarations.metadata: application-defined route metadata.openApi: OpenAPI operation hints.options: route options.
Request Model
Request data is declared by HTTP location.
pathParams: {
id: z.string(),
},
query: {
includeCompleted: z.coerce.boolean().optional(),
},
headers: {
"x-request-id": z.string().optional(),
},
body: {
title: z.string(),
},
Those locations remain part of the contract. Clients and server handlers receive one flattened request object.
await client.todos.update.fetch({
id: "todo_1",
includeCompleted: true,
"x-request-id": "req_1",
title: "Write docs",
});
const routes = router(api, {
todos: {
update({ id, includeCompleted, title, "x-request-id": requestId }) {
return updateTodo({ id, includeCompleted, title, requestId });
},
},
});
The flattened shape is only the TypeScript and adapter API. rest-rpc still
serializes each value to the HTTP location where it was declared.
Path Params
Full dynamic path segments infer string path params. In this route, :id
contributes an id: string input:
get: {
method: "GET",
path: "/todos/:id",
response: todoSchema,
}
Explicit pathParams replace the inferred string schema for the same params.
get: {
method: "GET",
path: "/todos/:id",
pathParams: {
id: z.string().uuid(),
},
response: todoSchema,
}
Path parameter rules:
- Only full path segments are params:
:idand{id}are params. - Partial segments are static:
/files/file-{id}and/todos/:id.jsondo not declare params. - Inferred params are string schemas.
- Explicit
pathParamsmust match the path params exactly. pathPrefixmust be static and cannot include path params.
Flattened Key Collisions
Every flattened request key must identify exactly one HTTP location. A route is invalid when the same key appears in multiple request locations.
pathParams: {
id: z.string(),
},
body: {
id: z.string(),
},
Both fields would flatten to id, so router() rejects the contract.
Other request key constraints:
contextis reserved for the server handler context.- Header keys are checked case-insensitively, so
authorizationandAuthorizationcollide. - Explicit path params in the URL must have matching
pathParamsschema keys. - Explicit
pathParamsschema keys must correspond to actual path params.
JSON Query
jsonQuery() declares a JSON-shaped query value.
import { jsonQuery, router } from "@rest-rpc/core";
const api = router({
search: {
method: "GET",
path: "/search",
query: jsonQuery(
z.object({
page: z.number(),
includeArchived: z.boolean(),
filters: z.object({
tags: z.array(z.string()),
}),
}),
),
response: z.object({ results: z.array(z.string()) }),
},
});
The client and handler use a top-level query value:
await client.search.fetch({
query: {
page: 2,
includeArchived: false,
filters: { tags: ["docs", "api"] },
},
});
rest-rpc transports that value in the URL query parameter named query,
parses it on the server, and validates it with the wrapped schema.
Responses
The response field declares one successful response body.
create: {
method: "POST",
path: "/todos",
body: {
title: z.string().min(1),
},
response: todoSchema,
}
rest-rpc assigns the status from the HTTP method: POST uses 201, and other
methods use 200.
The responses field declares response bodies by status code.
responses: {
200: todoSchema,
404: z.object({
code: z.literal("TODO_NOT_FOUND"),
}),
}
Non-2xx statuses are declared the same way as 2xx statuses.
Routes with no response or responses default to a no-body response. DELETE
uses 204, POST uses 201, and other methods use 200.
Route status specific response headers can be declared using response or
responses with an object containing body and headers.
create: {
method: "POST",
path: "/todos",
body: {
title: z.string().min(1),
},
response: {
body: todoSchema,
headers: {
location: z.string(),
"x-next-cursor": z.string().optional(),
},
},
}
Shared Router Options
router() can apply shared fields to every route in a tree.
export const api = router(
{
todos: {
list: {
method: "GET",
path: "/todos",
response: z.object({
items: z.array(todoSchema),
}),
openApi: {
summary: "List todos",
},
},
},
},
{
pathPrefix: "/api",
metadata: {
auth: "required",
},
commonHeaders: {
"x-request-id": z.string().optional(),
},
commonResponses: {
401: z.object({
code: z.literal("UNAUTHORIZED"),
}),
},
commonOpenApi: {
tags: ["Todos"],
security: [{ bearerAuth: [] }],
responses: {
200: {
headers: {
"x-request-id": z.string(),
},
},
401: {
description: "Authentication is required.",
},
},
},
},
);
The route above is normalized to GET /api/todos, includes the common header,
includes the common 401 response, and keeps the route-specific OpenAPI summary.
The commonOpenApi.responses headers are documentation-only. They do not require
handlers to return responseHeaders, add client responseHeaders, validate
runtime responses, or send headers from the server.
Single Routes
route() declares one route.
import { route } from "@rest-rpc/core";
export const getTodo = route({
method: "GET",
path: "/todos/:id",
response: todoSchema,
});
Beyond JSON Contracts
Advanced declarations are covered separately: