6. Validation and typed input
Status: Runnable. Everything on this page works today.
Avi.js validates route input with any library that implements Standard Schema: Zod (3.24+), Valibot, ArkType, Effect Schema and others. There are no adapters to install and no validation library is bundled. The examples below use Valibot; swap in your own.
Validating a route
Give the route a schema with any of params, query and body. Wrap it in route() so TypeScript infers the types:
import { defineController, route } from '@avijs/core';
import * as v from 'valibot';
const CreateUser = v.object({
name: v.pipe(v.string(), v.minLength(1, 'name is required')),
email: v.pipe(v.string(), v.email()),
});
const users = defineController({
prefix: '/users',
routes: [
route({
method: 'POST',
path: '/',
schema: { body: CreateUser },
handler: (ctx) => {
const { name, email } = ctx.input.body; // { name: string; email: string }
return reply(service.create(name, email), { status: 201 });
},
}),
],
});Validated data lands on ctx.input.params, ctx.input.query and ctx.input.body, typed from each schema's output. The raw request is untouched: ctx.params, ctx.query and ctx.json() still work.
With a body schema, Avi.js reads the JSON for you, with the usual rules: 413 over the body limit, 415 for a non-JSON content type, 400 for malformed JSON.
Strings in, types out
Path parameters and query values arrive as strings. A query key that repeats arrives as an array of strings:
| Request | What the query schema receives |
|---|---|
?page=2 | { page: '2' } |
?tag=a&tag=b | { tag: ['a', 'b'] } |
| (no query) | {} |
Converting is the schema's job, so you always decide what is valid:
const Paging = v.object({
page: v.optional(v.pipe(v.string(), v.transform(Number), v.integer(), v.minValue(1)), '1'),
});
const UserId = v.object({ id: v.pipe(v.string(), v.regex(/^\d+$/), v.transform(Number)) });
route({
method: 'GET',
path: '/:id',
schema: { params: UserId, query: Paging },
handler: (ctx) => service.page(ctx.input.params.id, ctx.input.query.page), // both numbers
});With Zod, the same is z.coerce.number().int().min(1).
Typed path parameters
route() also types ctx.params from the path, with no schema needed:
route({
method: 'GET',
path: '/:userId/posts/:postId',
handler: (ctx) => `${ctx.params.userId} / ${ctx.params.postId}`, // both string
});Using a parameter that is not in the path is a compile error. A controller prefix's parameters are not part of the route's path type; read them with ctx.params['name'].
When validation fails
Avi.js checks params, query and body, collects every issue, and answers 400:
{
"statusCode": 400,
"error": "Bad Request",
"message": "Validation failed",
"details": {
"issues": [
{ "in": "params", "path": ["id"], "message": "Invalid format" },
{ "in": "body", "path": ["name"], "message": "name is required" }
]
}
}The handler does not run. To use another format, read error.details in your errorHandler. You can attach details to your own errors too: new HttpError(409, 'Email taken', { details: { field: 'email' } }). Details are only sent for 4xx errors.
Where validation runs
route match → middleware → guards → validation → handler → response schema → responseA request that a guard rejects never has its body read or validated.
Response schemas
A response schema checks what the handler returns and sends the schema's output. Fields the schema does not declare are dropped, so internal data cannot leak by accident:
const PublicUser = v.object({ id: v.number(), name: v.string() });
route({
method: 'GET',
path: '/me',
schema: { response: PublicUser },
handler: (ctx) => service.findWithSecrets(ctx.state.user!.id), // passwordHash is dropped
});With a response schema, TypeScript also checks the handler's return type. If the value does not match at runtime, that is a bug: Avi.js logs it and answers 500. Status and headers from reply() are kept.
With decorators
Route decorators take the same schema:
@Controller('/users')
class UsersController {
@Post('/', { schema: { body: CreateUser, response: PublicUser } })
create(ctx: Context<{ body: v.InferOutput<typeof CreateUser> }>) {
return reply(this.users.create(ctx.input.body), { status: 201 });
}
@Get('/:id', { schema: { params: UserId } })
one(ctx: Context<{ params: { id: number } }, { id: string }>) {
return this.users.find(ctx.input.params.id);
}
}Decorators cannot change a method's parameter types, so annotate ctx: the first type argument is ctx.input, the second ctx.params. If the annotation does not match the schema, TypeScript reports an error on the decorator.
JavaScript
Everything above works the same without TypeScript: invalid input gets the same 400, and response schemas filter the same way. You only lose the inferred types.