Skip to content

Validation

A route validates a part of the request by declaring a schema for it. The handler then gets that part typed from the schema’s output.

Any Standard Schema works. Zod, Valibot and ArkType support it directly, and TypeBox does through tb() from @tetsujs/typebox.

import { controller, route } from "@tetsujs/core";
import { z } from "zod";
const NoteId = z.object({ id: z.coerce.number().int().positive() });
const NoteChange = z.object({ title: z.string().min(1).optional(), body: z.string().optional() });
export const notesController = controller("Notes", () => ({
change: route({
method: "PATCH"

The method this route answers.

Kept as a literal rather than widened to

Method

: the method is half of a route's identity, and an application that remembers its routes — for a generated client, for tooling — needs to know which one this is.

method
: "PATCH",
path: "/notes/:id"

Route path with :param segments, e.g. "/orders/:id/cancel".

Must start with /, contain no empty segments and no trailing slash; a malformed literal is a compile error.

path
: "/notes/:id",
schema: { params: NoteId, body: NoteChange },
handler: (
ctx: {
readonly out: Outgoing;
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: number;
};
readonly body: {
title?: string | undefined;
body?: string | undefined;
};
}
ctx
) => ({
id: number
id
:
ctx: {
readonly out: Outgoing;
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: number;
};
readonly body: {
title?: string | undefined;
body?: string | undefined;
};
}
ctx
.params.
id: number
id
, ...
ctx: {
readonly out: Outgoing;
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: number;
};
readonly body: {
title?: string | undefined;
body?: string | undefined;
};
}
ctx
.
body: {
title?: string | undefined;
body?: string | undefined;
}
body
}),
params: {
id: number;
}
}),
}));

A value the schema converts arrives converted: ctx.params.id is a number here.

Part Validates Adds
params path parameters, as strings ctx.params, narrowed
query the query string: a record of strings, a repeated key as an array ctx.query
headers the request headers, names in lower case ctx.headers
cookies the request’s cookies, signed ones already verified ctx.cookies
body the parsed body — see Request bodies ctx.body
response what the handler returns nothing; it checks what leaves

A part without a schema is neither read nor typed. Without a query schema the query string is not parsed and ctx.query does not exist. Without a body schema, bodyType or rawBody, the body is never read. Context explains why.

If a beforeValidation hook changes ctx.body or ctx.query, the schema validates the hook’s value.

All parts are checked, in the order params, query, headers, cookies, body, and their issues are collected. The client sees everything wrong with a request in one answer. A failure answers 422 before the handler runs:

{
"status": 422,
"message": "Validation failed",
"error": "VALIDATION_FAILED",
"issues": [
{ "message": "Invalid option: expected one of \"yes\"|\"no\"", "path": ["query", "draft"] },
{ "message": "Too small: expected string to have >=1 characters", "path": ["body", "title"] }
]
}

Each issue’s path starts with the part, then the keys inside it. The messages come from the validator and change with its version, so branch on error and path, and show message to people.

To answer 400 instead, set validation: { status: 400 } on createApp. A body that is not valid JSON is always a 400. To change the format of the answer, use an application onError hook; see Errors.

Path parameters, query values, headers, cookies and form fields all arrive as strings. The framework does not guess types; the schema converts them. With Zod, use z.coerce:

const Page = z.object({
page: z.coerce.number().int().min(1).default(1),
size: z.coerce.number().int().min(1).max(100).default(20),
});

Arrays need the same care. A key sent once is a string, and only a repeated key becomes an array: ?tag=a is "a", and ?tag=a&tag=b is ["a", "b"]. A plain z.array() refuses a single tag, so wrap a single value in an array first:

const
const one: (value: unknown) => unknown
one
= (
value: unknown
value
: unknown) => (typeof
value: unknown
value
=== "string" ? [
value: string
value
] :
value: unknown
value
);
const Filter = z.object({
tag: z.preprocess(
const one: (value: unknown) => unknown
one
, z.array(z.string())).optional(),
});

Form fields work the same way. With TypeBox, tb(…, { convert: true }) does both conversions: "42" to 42, and "a" to ["a"].

Only the schema changes between libraries; the route and the handler’s types follow it:

import { route } from "@tetsujs/core";
import { type } from "arktype";
import * as v from "valibot";
import { z } from "zod";
import { Type, tb } from "@tetsujs/typebox";
const WithZod = z.object({ title: z.string().min(1), pinned: z.boolean() });
const
const WithValibot: v.ObjectSchema<{
readonly title: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
readonly pinned: v.BooleanSchema<undefined>;
}, undefined>
WithValibot
= v.object({
title: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>
title
: v.pipe(v.string(), v.minLength(1)),
pinned: v.BooleanSchema<undefined>
pinned
: v.boolean(),
});
const
const WithArkType: Type<{
title: string;
pinned: boolean;
}, {}>
WithArkType
= type({
title: "string > 0"
title
: "string > 0",
pinned: "boolean"
pinned
: "boolean" });
const
const WithTypeBox: TypeBoxSchema<Type.TObject<{
title: Type.TString;
pinned: Type.TBoolean;
}>>
WithTypeBox
= tb(Type.Object({
title: Type.TString
title
: Type.String({
minLength?: number | undefined

Specifies the minimum number of characters allowed in the string. Must be a non-negative integer.

minLength
: 1 }),
pinned: Type.TBoolean
pinned
: Type.Boolean() }));
route({
method: "POST"

The method this route answers.

Kept as a literal rather than widened to

Method

: the method is half of a route's identity, and an application that remembers its routes — for a generated client, for tooling — needs to know which one this is.

method
: "POST",
path: "/notes"

Route path with :param segments, e.g. "/orders/:id/cancel".

Must start with /, contain no empty segments and no trailing slash; a malformed literal is a compile error.

path
: "/notes",
schema?: {
readonly body: TypeBoxSchema<Type.TObject<{
title: Type.TString;
pinned: Type.TBoolean;
}>>;
} | undefined

Validation schemas; an absent part is neither parsed nor typed.

schema
: {
body: TypeBoxSchema<Type.TObject<{
title: Type.TString;
pinned: Type.TBoolean;
}>>
body
:
const WithTypeBox: TypeBoxSchema<Type.TObject<{
title: Type.TString;
pinned: Type.TBoolean;
}>>
WithTypeBox
},
handler: (
ctx: {
readonly params: {};
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly body: {
title: string;
pinned: boolean;
};
}
ctx
) =>
ctx: {
readonly params: {};
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly body: {
title: string;
pinned: boolean;
};
}
ctx
.body,
body: {
title: string;
pinned: boolean;
}
});

tb() options such as convert, clean and defaults are on the package page. For a schema to appear in the OpenAPI document it must also produce JSON Schema. Zod, ArkType and tb() do this themselves; Valibot needs toStandardJsonSchema from @valibot/to-json-schema. See @tetsujs/openapi.

Asynchronous validators work too.

schema.response checks what the handler returns, at compile time and at runtime, and the schema’s output is what gets sent: a schema that strips unknown keys keeps a field like passwordHash out of the response. A response that fails its schema is a 500, reported to reportError. Responses covers a schema per status, what is serialized, and turning the runtime check off with validateResponses: false.