Skip to content

2. Validation

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.

src/index.ts
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.

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

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.

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.

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.

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.

Errors.