Skip to content

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.

Key Validates
params path parameters
query the query string
body the request body
headers request headers
response what your handler returns, keyed by status

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.

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

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.

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 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
)
what the client receives
{ "id": "1", "email": "[email protected]" }

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

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.

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.

headers: z.object({ 'x-api-version': z.enum(['1', '2']) })

Header names are matched case-insensitively, as HTTP defines them.

Ordering matters, and it is fixed:

  1. Middleware — wraps everything, sees unmatched requests too
  2. Brick request() hooks — ctx.user is populated here
  3. beforeHandle hooks — cheap rejections before any parsing cost
  4. Validation
  5. The handler

A guard rejecting a request never pays to parse a body, and a handler never sees input it did not ask for.

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.