Validation
Stratal validates incoming requests automatically using Zod schemas. Define your schemas in the @Route decorator, and Stratal takes care of the rest: it validates the data before your handler runs, returns structured error responses when validation fails, and documents everything in the OpenAPI spec.
Defining schemas
Section titled “Defining schemas”The @Route decorator accepts Zod schemas for three parts of a request: body, params, and query. Import z from stratal/validation to define your schemas.
import { Controller, IController, Route, RouterContext } from 'stratal/router'import { z } from 'stratal/validation'
const createUserSchema = z.object({ name: z.string().min(1), email: z.string().email(), role: z.enum(['admin', 'member']).default('member'),})
const userResponseSchema = z.object({ id: z.string(), name: z.string(), email: z.string(), role: z.string(),})
@Controller('/api/users', { tags: ['Users'] })export class UsersController implements IController { @Route({ body: createUserSchema, response: userResponseSchema, summary: 'Create a new user', }) async create(ctx: RouterContext) { const body = await ctx.body<{ name: string; email: string; role: string }>() // body is guaranteed to match createUserSchema at this point return ctx.json({ id: '1', ...body }, 201) }}When a request reaches the create handler, the body has already been validated against createUserSchema. If the body is missing required fields or has invalid values, Stratal returns a 400 response before the handler executes.
Request body validation
Section titled “Request body validation”Use the body property to validate JSON request bodies on POST, PUT, and PATCH routes:
@Route({ body: z.object({ title: z.string().min(1).max(200), content: z.string(), published: z.boolean().default(false), }), response: postResponseSchema,})async create(ctx: RouterContext) { const data = await ctx.body<{ title: string; content: string; published: boolean }>() // data.title, data.content, data.published are all validated}Path parameter validation
Section titled “Path parameter validation”Use params to validate URL path parameters. This is especially useful for ensuring IDs are in the right format:
@Route({ params: z.object({ id: z.string().uuid(), }), response: userResponseSchema, summary: 'Get user by ID',})show(ctx: RouterContext) { const id = ctx.param('id') // guaranteed to be a valid UUID}If someone sends GET /api/users/not-a-uuid, they get a validation error before the handler runs.
Query parameter validation
Section titled “Query parameter validation”Use query to validate and coerce query string parameters:
@Route({ query: z.object({ page: z.coerce.number().int().positive().default(1), limit: z.coerce.number().int().min(1).max(100).default(20), search: z.string().optional(), }), response: userListResponseSchema,})index(ctx: RouterContext) { const { page, limit, search } = ctx.query() // page and limit are numbers (coerced from query strings)}Combining schemas
Section titled “Combining schemas”You can validate body, params, and query on the same route:
@Route({ params: z.object({ id: z.string().uuid() }), query: z.object({ fields: z.string().optional() }), body: z.object({ name: z.string().min(1), email: z.string().email(), }), response: userResponseSchema, summary: 'Update a user',})async update(ctx: RouterContext) { const id = ctx.param('id') const { fields } = ctx.query() const data = await ctx.body<{ name: string; email: string }>() // All three inputs are validated}Validation error responses
Section titled “Validation error responses”When validation fails, Stratal returns a structured 400 Bad Request response with details about every field that failed:
{ "code": 1003, "message": "Validation error", "timestamp": "2026-02-26T12:00:00.000Z", "metadata": { "issues": [ { "path": "email", "message": "Invalid email address", "code": "invalid_format" }, { "path": "name", "message": "Required", "code": "invalid_type" } ] }}Each issue in the metadata.issues array includes:
| Field | Description |
|---|---|
path | The field that failed (dot-notation for nested fields) |
message | A human-readable error message |
code | The Zod error code (invalid_type, too_small, invalid_format, etc.) |
Common error schemas
Section titled “Common error schemas”Stratal automatically adds common error response schemas to every route in the OpenAPI specification. You do not need to define these yourself:
| Status | Description |
|---|---|
| 400 | Validation error |
| 401 | Unauthorized |
| 403 | Forbidden |
| 404 | Not found |
| 409 | Conflict |
| 500 | Internal server error |
Your response property in @Route defines the success response. Error responses are handled by the framework.
Reusable schemas
Section titled “Reusable schemas”Keep your schemas in separate files for reuse across controllers:
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']).default('member'),})
export const userResponseSchema = z.object({ id: z.string().uuid(), name: z.string(), email: z.string(), role: z.string(), createdAt: z.string().datetime(),})
export const userListResponseSchema = z.object({ data: z.array(userResponseSchema), total: z.number(),})
// For type inference in your servicesexport type CreateUserDto = z.infer<typeof createUserSchema>export type UserResponse = z.infer<typeof userResponseSchema>Then import them in your controller:
import { createUserSchema, userResponseSchema } from '../schemas/user.schema'
@Controller('/api/users')export class UsersController implements IController { @Route({ body: createUserSchema, response: userResponseSchema, }) async create(ctx: RouterContext) { const data = await ctx.body<CreateUserDto>() // ... }}OpenAPI integration
Section titled “OpenAPI integration”Every Zod schema you pass to @Route is automatically reflected in the generated OpenAPI specification. Stratal re-exports z from @hono/zod-openapi, which extends Zod with an .openapi() method for richer API documentation.
Field descriptions
Section titled “Field descriptions”The simplest way to document fields is with .describe(), which maps directly to the description property in the OpenAPI spec:
const createProductSchema = z.object({ name: z.string().min(1).max(200).describe('Product display name'), price: z.number().positive().describe('Price in cents'), category: z.enum(['electronics', 'clothing', 'food']).describe('Product category'), tags: z.array(z.string()).max(10).optional().describe('Searchable tags'),})The .openapi() method
Section titled “The .openapi() method”For richer documentation, use .openapi() from @hono/zod-openapi. It accepts an object with example, description, deprecated, and other OpenAPI schema properties:
import { z } from 'stratal/validation'
const createProductSchema = z.object({ name: z .string() .min(1) .max(200) .openapi({ description: 'Product display name', example: 'Wireless Keyboard' }), price: z .number() .positive() .openapi({ description: 'Price in cents', example: 2999 }), sku: z .string() .regex(/^[A-Z]{2}-\d{6}$/) .openapi({ description: 'Stock keeping unit', example: 'KB-001234' }), tags: z .array(z.string()) .max(10) .optional() .openapi({ description: 'Searchable tags', example: ['electronics', 'peripherals'] }),})The example values appear in the generated OpenAPI spec and are displayed in documentation tools like Swagger UI, making your API easier to explore.
Registering reusable schemas
Section titled “Registering reusable schemas”Pass a string to .openapi() to register a schema as a reusable $ref component in the OpenAPI specification:
export const userSchema = z .object({ id: z.string().uuid(), name: z.string(), email: z.string().email(), role: z.enum(['admin', 'member', 'viewer']), createdAt: z.string().datetime(), }) .openapi('User')
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')When you pass 'User' to .openapi(), the schema is emitted once in the OpenAPI spec under components.schemas.User and referenced with $ref: '#/components/schemas/User' wherever it is used. This keeps the generated spec clean and avoids duplicating schema definitions across routes.
Path and query parameter metadata
Section titled “Path and query parameter metadata”Use .openapi() with a param property to add OpenAPI parameter metadata to path or query parameters:
const userParamsSchema = z.object({ id: z .string() .uuid() .openapi({ param: { name: 'id', in: 'path' }, example: '550e8400-e29b-41d4-a716-446655440000' }),})
const listQuerySchema = z.object({ page: z.coerce .number() .int() .positive() .default(1) .openapi({ param: { name: 'page', in: 'query' }, example: 1 }), limit: z.coerce .number() .int() .min(1) .max(100) .default(20) .openapi({ param: { name: 'limit', in: 'query' }, example: 20 }),})i18n error messages
Section titled “i18n error messages”When the Internationalization module is configured, validation error messages are automatically translated based on the request locale. Stratal includes built-in translations for all standard Zod error types.
The locale is determined from the X-Locale request header. If no header is present, the default locale is used.
// Request with X-Locale: frPOST /api/users{ "email": "invalid" }
// Response messages are in French{ "code": 1003, "message": "Erreur de validation", "metadata": { "issues": [ { "path": "email", "message": "Adresse e-mail invalide", "code": "invalid_format" } ] }}Built-in translations cover all standard Zod error codes including required, invalid_type, too_small, too_big, invalid_format, and more.
How validation works internally
Section titled “How validation works internally”Understanding the validation pipeline can help when debugging:
- A request arrives and Stratal creates a request-scoped DI container with i18n context.
- The Zod schemas from
@Routeare passed to@hono/zod-openapi, which validates the request. - If validation fails, a
SchemaValidationErroris created from theZodError. - The
GlobalErrorHandlertranslates the error message using the request’s locale. - The error is serialized to the standard error response format and returned as a
400. - If validation passes, the handler runs with guaranteed-valid data.
Next steps
Section titled “Next steps”- Error Handling to learn about custom error types and the error code system.
- OpenAPI Schemas and Validation for more on how schemas generate API documentation.
- Controllers and Routing for the full
@Routeconfiguration options.