Schemas and Validation
Every Zod schema you attach to a @Route decorator serves two purposes: it validates incoming requests at runtime and it generates the corresponding section in the OpenAPI spec. You write one schema and get both behaviors for free.
Defining schemas
Section titled “Defining schemas”Import z from stratal/validation and define your schemas as regular Zod objects. Call .openapi('Name') on a schema to register it as a reusable component in the spec under #/components/schemas/Name:
import { z } from 'stratal/validation'
export const createUserSchema = z .object({ name: z.string().min(1).max(100), email: z.string().email(), role: z.enum(['admin', 'member', 'viewer']).default('member'), }) .openapi('CreateUser')
export const userSchema = z .object({ id: z.string(), name: z.string(), email: z.string().email(), role: z.enum(['admin', 'member', 'viewer']), createdAt: z.string(), }) .openapi('User')Schemas without .openapi() are still used for validation and appear inline in the spec, but they will not show up as named components.
Request body
Section titled “Request body”Use the body property on @Route to validate JSON request bodies. This is typically used with create, update, and patch methods:
@Route({ body: createUserSchema, response: userResponseSchema, summary: 'Create a user',})async create(ctx: RouterContext) { const body = await ctx.body<z.infer<typeof createUserSchema>>() // body is fully validated at this point return ctx.json({ data: body }, 201)}In the generated spec, the body schema appears under requestBody.content.application/json.schema.
URL parameters
Section titled “URL parameters”Use the params property to validate URL path parameters. The schema must be a z.object() whose keys match the parameter names in the route path:
@Route({ params: z.object({ id: z.string().uuid() }), response: userResponseSchema, summary: 'Get user by ID',})show(ctx: RouterContext) { const id = ctx.param('id') // id has been validated as a UUID return ctx.json({ data: { id } })}For the common case of a single UUID id parameter, Stratal provides a built-in schema:
import { uuidParamSchema } from 'stratal/router/schemas'
@Route({ params: uuidParamSchema, response: userResponseSchema,})show(ctx: RouterContext) { // ...}Query parameters
Section titled “Query parameters”Use the query property to validate query string parameters:
import { paginationQuerySchema } from 'stratal/router/schemas'
@Route({ query: paginationQuerySchema, response: userListSchema, summary: 'List users with pagination',})index(ctx: RouterContext) { const { page, limit } = ctx.query<{ page: number; limit: number }>() // page and limit are validated and coerced to numbers return ctx.json({ data: [], pagination: { page, limit, total: 0, totalPages: 0 } })}Response schemas
Section titled “Response schemas”The response property defines what the endpoint returns on success. Every @Route must include a response schema:
export const userResponseSchema = z .object({ data: userSchema, }) .openapi('UserResponse')
@Route({ response: userResponseSchema,})index(ctx: RouterContext) { return ctx.json({ data: { id: '1', name: 'Alice' } })}Custom content types
Section titled “Custom content types”By default, body and response schemas use application/json as the content type. You can specify a different content type by passing an object instead of a bare Zod schema.
Request body with custom content type
Section titled “Request body with custom content type”The body property accepts a RouteBody, which is either a ZodType (defaults to application/json) or a RouteBodyObject:
type RouteBodyObject = { schema: ZodType contentType?: string // defaults to 'application/json'}For example, to accept multipart/form-data for file uploads:
@Route({ body: { schema: z.object({ file: z.any(), description: z.string().optional(), }), contentType: 'multipart/form-data', }, response: z.object({ url: z.string().url() }), summary: 'Upload a file', statusCode: 201,})async uploadFile(ctx: RouterContext) { // handle file upload return ctx.json({ url: 'https://cdn.example.com/files/doc.pdf' }, 201)}Response with custom content type
Section titled “Response with custom content type”The response property accepts a RouteResponse, which is either a ZodType or a RouteResponseObject:
type RouteResponseObject = { schema: ZodType description?: string contentType?: string // defaults to 'application/json'}For example, to return binary data with application/octet-stream:
@Route({ params: z.object({ id: z.string().uuid() }), response: { schema: z.any(), contentType: 'application/octet-stream', description: 'The raw file contents', }, summary: 'Download a file',})downloadFile(ctx: RouterContext) { // return binary response}Note: Auto-included error responses (400, 401, 403, 404, 409, 500) always use
application/jsonregardless of the route’s content type.
Built-in schemas
Section titled “Built-in schemas”Stratal provides several reusable schemas you can import from stratal/router/schemas:
| Schema | Description |
|---|---|
uuidParamSchema | { id: string } validated as UUID. Registered as UUIDParam. |
paginationQuerySchema | { page: number, limit: number } with defaults (page 1, limit 20, max 100). Registered as PaginationQuery. |
successMessageSchema | { message: string, data?: object }. Registered as SuccessMessage. |
errorResponseSchema | Standard error shape with code, message, timestamp, and optional metadata. Registered as ErrorResponse. |
validationErrorResponseSchema | Error shape with validation issues array. Registered as ValidationErrorResponse. |
paginatedResponseSchema(itemSchema) | Factory that wraps an item schema in a { data, pagination } envelope. |
Auto-included error responses
Section titled “Auto-included error responses”You do not need to define error responses on your routes. Stratal automatically includes these common error schemas on every OpenAPI route:
| Status | Description | Schema |
|---|---|---|
| 400 | Validation error | ValidationErrorResponse |
| 401 | Unauthorized | ErrorResponse |
| 403 | Forbidden | ErrorResponse |
| 404 | Not found | ErrorResponse |
| 409 | Conflict | ErrorResponse |
| 500 | Internal server error | ErrorResponse |
Your @Route only needs to define the success response. The error responses are merged in automatically.
Inferring TypeScript types
Section titled “Inferring TypeScript types”Use z.infer to derive TypeScript types from your Zod schemas, keeping your types and validation in sync:
import { z } from 'stratal/validation'
export const userSchema = z.object({ id: z.string(), name: z.string(), email: z.string().email(), role: z.enum(['admin', 'member', 'viewer']), createdAt: z.string(),}).openapi('User')
export type User = z.infer<typeof userSchema>// { id: string; name: string; email: string; role: 'admin' | 'member' | 'viewer'; createdAt: string }Full example
Section titled “Full example”Here is a complete schemas file for a users feature:
import { z } from 'stratal/validation'
export const createUserSchema = z .object({ name: z.string().min(1).max(100), email: z.string().email(), role: z.enum(['admin', 'member', 'viewer']).default('member'), }) .openapi('CreateUser')
export const updateUserSchema = z .object({ name: z.string().min(1).max(100).optional(), email: z.string().email().optional(), role: z.enum(['admin', 'member', 'viewer']).optional(), }) .openapi('UpdateUser')
export const userSchema = z .object({ id: z.string(), name: z.string(), email: z.string().email(), role: z.enum(['admin', 'member', 'viewer']), createdAt: z.string(), }) .openapi('User')
export const userListSchema = z .object({ data: z.array(userSchema), }) .openapi('UserList')
export const userResponseSchema = z .object({ data: userSchema, }) .openapi('UserResponse')
export type User = z.infer<typeof userSchema>Next steps
Section titled “Next steps”See Tags and Security to learn how to group endpoints and apply authentication requirements in the spec.