Skip to content

Shared contracts and request validation

View as Markdown

A shared contract describes one HTTP operation as data: method, path, input, responses, and documentation. The server uses it to parse requests and type the handler. OpenAPI and the browser client use the same value, so the wire shape does not have to be recreated at each boundary.

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.

Row projections — the columns a screen reads from a generated table route — belong here too. They are application-declared wire shapes, not browser-private types.

Suppose one operation must change a task and record a history event together. The contract for that bounded slice lives in packages/shared/src/contracts/complete-task.ts:

import { errorBodySchema } from "@sapporta/shared/contracts";
import { initContract } from "@sapporta/rest-core";
import { z } from "zod";
const c = initContract();
export const taskCompletionErrorSchema = z
.object({
error: z.string(),
code: z.enum(["TASK_NOT_FOUND", "TASK_ALREADY_COMPLETED"]),
})
.strict();
export type TaskCompletionErrorBody = z.output<
typeof taskCompletionErrorSchema
>;
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"),
}),
400: errorBodySchema,
404: taskCompletionErrorSchema,
409: taskCompletionErrorSchema,
},
}),
});

packages/shared imports initContract from @sapporta/rest-core. Although a server package may re-export that helper, depending on @sapporta/server from the shared leaf would move a server-oriented dependency toward the browser.

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,
taskCompletionErrorSchema,
type TaskCompletionErrorBody,
} 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 and the feature-error schema from the project shared package.

The strict empty object means this action accepts {} and rejects caller-owned fields. The exported error schema is also strict and names only the two feature-owned codes. The adapter’s request failures remain the generic errorBodySchema at 400.

Authentication middleware can still end a protected request with the application’s shared 401 or 403 envelope before the feature handler runs. That infrastructure behavior is documented once outside feature contracts; it is not repeated in this response map.

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. Take SAPPORTA_API_PORT from this project’s .env.development; pnpm dev prints it as the API URL when it starts.

Terminal window
curl -i -X POST "http://localhost:$SAPPORTA_API_PORT/api/tasks/12/complete" \
-H 'Content-Type: application/json' \
-d '{}'
curl -i -X POST "http://localhost:$SAPPORTA_API_PORT/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. Malformed JSON or a failed path, query, header, or body parse returns an adapter-generated 400 before the handler runs, so declare that response when it belongs in OpenAPI.

Response schemas provide server types and OpenAPI shapes; the server adapter does not parse a handler’s response body. The generated browser client validates responses by default. A malformed server response can therefore fail at the client boundary even though the handler returned it.

After the route is mounted, build the project and inspect the registered operation:

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 contract owns wire data. The API owns authorization, row visibility, persistence, and effects.