Skip to content

Shared contracts and request validation

View as Markdown

A shared contract describes one HTTP operation as data: its method, path, inputs, responses, and documentation metadata. Sapporta uses that same value to validate requests, type the route handler, generate OpenAPI, and create the browser client.

This page shows where contracts belong, how request validation behaves, and how to model success and expected failures. These concepts support command endpoints, report parameters, imports, webhooks, and any other app-owned API that needs a stable wire format.

Add a shared contract for completing a task. Coerce the numeric path ID, accept an empty body, declare 200, 404, and 409 responses, export it from the shared package, and build the workspace.

Keep the wire boundary in the shared package

Section titled “Keep the wire boundary in the shared package”

Create one contract file per feature under packages/shared/src/contracts/. The shared package may contain Zod schemas, contracts, wire types, constants, and pure serializers. It remains a leaf package: React components, Hono handlers, Drizzle queries, database handles, and file I/O stay in their owning packages.

The task app uses a contract for an operation that changes a task and records a history event together. Create packages/shared/src/contracts/complete-task.ts:

import { initContract } from "@sapporta/rest-core";
import { z } from "zod";
const c = initContract();
const errorSchema = z.object({
error: z.string(),
code: z.string(),
});
export const completeTaskContract = c.router({
completeTask: c.mutation({
method: "POST",
path: "/tasks/:id/complete",
summary: "Complete a task and record the event",
metadata: { tags: ["tasks"] },
pathParams: z.object({
id: z.coerce.number().int().positive(),
}),
body: z.object({}).strict(),
responses: {
200: z.object({
task_id: z.number().int(),
event_id: z.number().int(),
status: z.literal("completed"),
}),
404: errorSchema,
409: errorSchema,
},
}),
});

The path is /tasks/:id/complete, not /api/tasks/:id/complete. The API application is already mounted under /api; repeating that prefix in the contract would produce the wrong URL.

Re-export the contract from packages/shared/src/contracts/index.ts:

export { completeTaskContract } from "./complete-task.js";

packages/shared/src/index.ts already re-exports that barrel in a generated project. Both the API package and frontend package can now import completeTaskContract from task-app-shared.

TsRestApi parses pathParams, query, headers, and body with the contract schemas before it invokes the handler. The numeric coercion above turns the path segment "12" into request.params.id === 12. A non-numeric ID fails at the boundary, so domain code does not need another parser.

Run the task app, then compare a valid and invalid request:

Terminal window
curl -i -X POST http://localhost:3000/api/tasks/12/complete \
-H 'Content-Type: application/json' \
-d '{}'
curl -i -X POST http://localhost:3000/api/tasks/not-a-number/complete \
-H 'Content-Type: application/json' \
-d '{}'

Before the route is implemented, the first request may return 404. After registration, the invalid path is rejected before the handler runs:

{
"error": "Invalid request",
"code": "BAD_REQUEST",
"details": [
{
"path": ["id"],
"message": "Invalid input: expected number, received NaN"
}
]
}

Request schemas define the runtime input boundary. Response schemas provide TypeScript and OpenAPI shapes; the route handler remains responsible for returning a body that matches the declared response. Declare every expected non-2xx status so the route and client can preserve its stable payload.

Build all three packages and inspect the registered operation after its route is mounted:

Terminal window
pnpm build
pnpm exec sapporta endpoints show "POST /api/tasks/{id}/complete"

A successful endpoint inspection should report the method, mounted path, request body, and declared responses. If it reports no route, check the route mount before changing the contract.

The task app now has one browser-safe definition for validation, handler inference, OpenAPI, and client inference. The important separation is that the contract owns wire data while the API owns authorization and persistence. From here, register the contract in a custom endpoint, then import the same value into a typed browser client. Query schemas, multipart bodies, and non-JSON responses use the same boundary and add only the fields their transport requires.