---
title: Declaration
description: Learn how to declare contracts with rest-rpc
---

A contract is a plain TypeScript object passed through `router()`.

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

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

```ts
await client.todos.update.fetch({
	id: "todo_1",
	includeCompleted: true,
	"x-request-id": "req_1",
	title: "Write docs",
});
```

```ts
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:

```ts
get: {
	method: "GET",
	path: "/todos/:id",
	response: todoSchema,
}
```

Explicit `pathParams` replace the inferred string schema for the same params.

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

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

```ts
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:

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

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

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

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

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

```ts
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:

- [HTTP Requests](../http-requests.mdx)
- [HTTP Responses](../http-responses.mdx)
- [WebSockets](../websockets.mdx)
