Validation
export default route( { params: z.object({ id: z.uuid() }), query: z.object({ page: z.coerce.number().int().min(1).default(1) }), body: z.object({ title: z.string().min(1).max(200) }), }, (ctx) => ({ id: ctx.params.id, // string, a valid UUID page: ctx.query.page, // number title: ctx.body.title, // string, 1–200 chars }),)One declaration does three jobs: it rejects bad input before your handler runs, it types ctx,
and it becomes the OpenAPI description of the endpoint. Writing the
schema twice — once to validate, once to document — is how the two drift apart.
What can be validated
Section titled “What can be validated”| Key | Validates |
|---|---|
params |
path parameters |
query |
the query string |
body |
the request body |
headers |
request headers |
response |
what your handler returns, keyed by status |
Any Standard Schema validator
Section titled “Any Standard Schema validator”Oven validates through Standard Schema, not through Zod. Zod is the
default because z.toJSONSchema() makes OpenAPI generation free — but nothing is coupled to it.
import * as v from 'valibot'
export default route( { body: v.object({ title: v.pipe(v.string(), v.minLength(1)) }) }, (ctx) => ctx.body.title, // typed, from Valibot)Valibot, ArkType and Effect Schema all work, and the test suite exercises Valibot alongside Zod precisely so “any Standard Schema validator” is a tested claim rather than a hopeful one.
Coercion is yours to ask for
Section titled “Coercion is yours to ask for”A query string has no types — every value arrives as a string. Oven does not guess:
query: z.object({ page: z.coerce.number().default(1), includeArchived: z.coerce.boolean().default(false),})Uploads
Section titled “Uploads”export default route( { body: z.object({ title: z.string(), file: z.file().max(5_000_000).mime(['image/png', 'image/jpeg']), }), }, (ctx) => ctx.storage.upload(`uploads/${ctx.body.file.name}`, ctx.body.file),)ctx.body.file is a web File. Multipart
bodies are streamed rather than buffered, and large parts spill to a temporary file — so a schema
declaring a file does not mean the whole upload sits in memory first.
Limits can also be set once for the whole app, which is the right place for a hard ceiling:
createApp({ body: { limit: 10_000_000, maxFiles: 5, allowedFileTypes: ['image/*'] } })The app-level limit is enforced during parsing, before any schema runs. A per-route z.file().max()
narrows it further; it cannot widen it.
When validation fails
Section titled “When validation fails”A 422, naming every offending field at once rather than the first:
{ "type": "about:blank", "title": "Unprocessable Content", "status": 422, "detail": "Request validation failed.", "errors": [ { "location": "body", "path": "email", "message": "Invalid email address" }, { "location": "body", "path": "age", "message": "Expected number, received string" } ]}location is params, query, body or headers; path is dotted for nested fields
(address.postcode) and bracketed for arrays (items[0].sku).
Assert on location and path in tests, never on message — validator messages get reworded
between versions, and a test that breaks when prose changes is a test that gets deleted.
Response validation
Section titled “Response validation”export default route( { response: { 200: z.object({ id: z.string(), title: z.string() }), 404: z.object({ error: z.string() }), }, }, handler,)Response schemas do three things: shape the OpenAPI document, filter the response body, and — in development — fail loudly when a route has drifted from its contract.
The schema filters the response
Section titled “The schema filters the response”The parsed output becomes the body, so a field the schema does not declare is not sent:
export default route( { response: { 200: z.object({ id: z.string(), email: z.email() }) } }, async (ctx) => ctx.db.select().from(users).where(eq(users.id, ctx.params.id)), // ^ the row also has passwordHash and resetToken on it)This is the reason to declare one. SELECT * into a handler’s return value is how credentials
leak, and the schema you wrote for documentation stops it — in production as well as development
(D29).
What happens when it does not match
Section titled “What happens when it does not match”| development | production | |
|---|---|---|
| parses | filtered body | filtered body |
| does not parse | 500, issues in the body |
error logged, unfiltered body, request succeeds |
The asymmetry is deliberate. Failing closed in production would turn a schema that has drifted
into an outage on the deploy that introduced it — a working endpoint replaced by a 500 over a
field nobody reads. So the request survives.
But a value that did not parse was not filtered either, so that log line carries
filtered: false. If a route is logging it, the schema is not protecting anything on that route.
validateResponses controls the failing half, and defaults to on in development, off in
production. Set createApp({ validateResponses: true }) to fail closed in production too.
Turning it off
Section titled “Turning it off”createApp({ serializeResponses: false })The handler’s value is sent untouched. Worth knowing before you reach for it: filtering costs about 480 ns on a route that declares a response schema, and nothing at all on a route that does not. That is roughly 5% of a real request over a socket, and 0.02% of a 2 ms database query.
A handler that takes control is not validated
Section titled “A handler that takes control is not validated”Returning a Response, a ReadableStream, a Blob, a Bun.file, a typed array or a URL
bypasses response validation entirely:
export default route( { response: { 200: z.object({ id: z.string() }) } }, (ctx) => Bun.file('./report.pdf'), // not checked against the schema)A response schema describes the JSON body a handler would otherwise have returned. It has nothing
to say about a stream or a file, and checking one against it used to produce a 500 on a route
that was working correctly.
A returned string is still validated, because response: { 200: z.string() } is a reasonable
contract for a text endpoint.
Validating headers
Section titled “Validating headers”headers: z.object({ 'x-api-version': z.enum(['1', '2']) })Header names are matched case-insensitively, as HTTP defines them.
Where validation sits
Section titled “Where validation sits”Ordering matters, and it is fixed:
- Middleware — wraps everything, sees unmatched requests too
- Brick
request()hooks —ctx.useris populated here beforeHandlehooks — cheap rejections before any parsing cost- Validation
- The handler
A guard rejecting a request never pays to parse a body, and a handler never sees input it did not ask for.
Without a schema
Section titled “Without a schema”ctx.body is still there — parsed, content-type aware, and typed unknown:
app.post('/webhook', async (ctx) => { const payload = await ctx.body // unknown; narrow it yourself})unknown rather than any on purpose: it forces the narrowing that a schema would have done for
you.