Skip to content
rest-rpc
Esc
navigateopen⌘Jpreview
On this page

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, or PATCH.
  • path: HTTP path with static segments and full dynamic :param or {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 from method.
  • 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: :id and {id} are params.
  • Partial segments are static: /files/file-{id} and /todos/:id.json do not declare params.
  • Inferred params are string schemas.
  • Explicit pathParams must match the path params exactly.
  • pathPrefix must 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:

  • context is reserved for the server handler context.
  • Header keys are checked case-insensitively, so authorization and Authorization collide.
  • Explicit path params in the URL must have matching pathParams schema keys.
  • Explicit pathParams schema 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:

Was this page helpful?