Shared contracts and request validation
View as MarkdownA 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.
Let the adapter parse the request
Section titled “Let the adapter parse the request”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:
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.
Check the shared boundary
Section titled “Check the shared boundary”Build all three packages and inspect the registered operation after its route is mounted:
pnpm buildpnpm 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.