2. Validation
One declaration, three jobs
Section titled “One declaration, three jobs”FastAPI’s insight was that a single declaration should validate the request, type the handler, and document the endpoint. Python could do that with type hints because they survive to runtime. TypeScript’s do not — so a schema takes their place and does the same three jobs.
import { z } from 'zod'
app.post( '/users/:id', { params: z.object({ id: z.coerce.number() }), body: z.object({ name: z.string().min(1), email: z.email() }), }, async (ctx) => { ctx.params.id // number, coerced and validated ctx.body.email // string, guaranteed to be an email return db.user.update(ctx.params.id, ctx.body) },)You wrote no generics and no interfaces. A bad request never reaches the handler.
What can be validated
Section titled “What can be validated”| Key | Validates |
|---|---|
params |
path parameters |
query |
the parsed query string |
body |
the parsed request body |
headers |
request headers, as a lowercase-keyed object |
response |
the handler’s result, by status code |
Failures
Section titled “Failures”A validation failure is an RFC 9457 problem document with every problem listed:
{ "type": "about:blank", "title": "Unprocessable Content", "status": 422, "detail": "Request validation failed.", "errors": [ { "location": "body", "path": "email", "message": "Invalid email address" }, { "location": "body", "path": "items[0].qty", "message": "Expected number, received string" }, { "location": "params", "path": "id", "message": "Invalid UUID" } ]}Every location is checked before failing. Reporting only the first problem turns fixing a request into a guessing game, one field per round trip.
Paths are rendered as items[0].qty rather than as a JSON array, so they can be pasted
straight back into client code.
Unlike an unexpected 500, these messages describe the caller’s own payload, so they are shown in production too. Nothing internal leaks by telling someone their email is malformed.
Coercion
Section titled “Coercion”Everything in a URL is a string. Say so in the schema:
{ params: z.object({ id: z.coerce.number() }), query: z.object({ page: z.coerce.number().default(1), includeArchived: z.coerce.boolean().default(false), }),}Oven does not silently coerce for you. It cannot know what you meant: turning "123" into
123 before the schema sees it would quietly break a z.string() field that legitimately holds
digits — an order number, a zip code, a phone number. Being explicit costs seven characters and
removes an entire category of surprise.
Response schemas
Section titled “Response schemas”app.get('/users/:id', { response: { 200: UserSchema } }, (ctx) => db.user.find(ctx.params.id))A response that does not match is a 500, not a 422: the caller did nothing wrong, your route drifted from its contract. In development the mismatch is described in the response; in production it is logged and withheld.
Response validation is on in development, off in production by default. It checks your own
code rather than untrusted input, so the bug it catches is one local development and CI should
already have caught — and unlike request validation, the cost is paid on every success. Turn it
on with createApp({ validateResponses: true }) if you would rather pay it.
Bring your own validator
Section titled “Bring your own validator”Oven accepts anything implementing Standard Schema — Zod, Valibot, ArkType, Effect Schema. You can even mix them on one route:
import * as v from 'valibot'
app.post( '/users/:id', { params: z.object({ id: z.coerce.number() }), // zod body: v.object({ name: v.string() }), // valibot }, (ctx) => ({ id: ctx.params.id, name: ctx.body.name }),)Zod is the bundled default because it emits JSON Schema natively, which is what will make OpenAPI generation free. It is not a requirement — and the test suite validates a Valibot schema end to end so that claim stays true.