Skip to content

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.

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.

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.

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) {
// ...
}

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 } })
}

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' } })
}

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.

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)
}

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/json regardless of the route’s content type.

Stratal provides several reusable schemas you can import from stratal/router/schemas:

SchemaDescription
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.
errorResponseSchemaStandard error shape with code, message, timestamp, and optional metadata. Registered as ErrorResponse.
validationErrorResponseSchemaError shape with validation issues array. Registered as ValidationErrorResponse.
paginatedResponseSchema(itemSchema)Factory that wraps an item schema in a { data, pagination } envelope.

You do not need to define error responses on your routes. Stratal automatically includes these common error schemas on every OpenAPI route:

StatusDescriptionSchema
400Validation errorValidationErrorResponse
401UnauthorizedErrorResponse
403ForbiddenErrorResponse
404Not foundErrorResponse
409ConflictErrorResponse
500Internal server errorErrorResponse

Your @Route only needs to define the success response. The error responses are merged in automatically.

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 }

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>

See Tags and Security to learn how to group endpoints and apply authentication requirements in the spec.