Validation
Edit docs/guide/06-validation.md on GitHubA route declares the shape of what it accepts on the decorator itself. The framework parses, validates and types the request before the handler runs.
import {
Controller,
Get,
Post,
type Input,
type RouteSchemas,
} from '@dunx/http';
import { z } from 'zod';
const CreateUser = z.object({ name: z.string().min(1).max(40) });
const createUser = { body: CreateUser } as const satisfies RouteSchemas;
@Controller('users')
export class UsersController {
constructor(private readonly users: UsersService) {}
@Post('/', createUser)
create({ body }: Input<typeof createUser>): Promise<User> {
return this.users.create(body.name);
}
}The body arrives parsed and validated: body.name is already a string,
and a POST answers 201, all without writing await req.json(), Response.json()
or a status code by hand. A request that fails the schema never reaches the
handler.
Standard Schema is the contract
What a route's body, query and params accept is anything implementing
Standard Schema v1: an object carrying a
~standard property.
@dunx/http needs no validator dependency to enforce
that. Its required peer is @dunx/core; @types/bun is an optional peer, as
are @bufbuild/protobuf and @connectrpc/connect, which only the
RPC surface loads. None of them is a validator.
export interface StandardSchemaV1<In = unknown, Out = In> {
readonly '~standard': {
readonly version: 1;
readonly vendor: string;
readonly validate: (
value: unknown,
) => StandardSchemaResult<Out> | Promise<StandardSchemaResult<Out>>;
readonly types?: { readonly input: In; readonly output: Out } | undefined;
};
}That interface is restated in @dunx/http rather than imported. The spec is
only an interface: @standard-schema/spec ships type declarations with no
runtime code, so restating it costs one file and adds no dependency to the
package. zod 4, Valibot and ArkType already satisfy the shape, so all three
drop straight into a route's options with no adapter needed:
import { z } from 'zod';
import * as v from 'valibot';
import { type } from 'arktype';
const withZod = { query: z.object({ limit: z.coerce.number() }) } as const;
const withValibot = { query: v.object({ limit: v.number() }) } as const;
const withArkType = { query: type({ limit: 'number' }) } as const;The one thing the framework reads is schema['~standard'].validate(value). It
does not know or care which library produced the object.
Sync and async validators
~standard.validate is permitted to return a promise, though zod, Valibot and
ArkType never do. The input reader is built around that difference: it checks
result instanceof Promise and only allocates a promise link when it gets one.
A validator that really is asynchronous still works and is awaited correctly; it
just costs an async frame that the synchronous ones do not.
RouteSchemas
The second argument to @Get / @Post / @Put / @Patch / @Delete:
export interface RouteSchemas {
readonly body?: StandardSchemaV1;
readonly query?: StandardSchemaV1;
readonly params?: StandardSchemaV1;
/** Overrides the default success status: 201 for POST, 200 otherwise. */
readonly status?: number;
/** What the route answers with, keyed by status. Documented, never validated. */
readonly response?: Readonly<Record<number, StandardSchemaV1>>;
}A field appears in the handler's input only when you declare its schema. Omit
query and the query string is never parsed. Omit body and the request body is
never read. There is no global validation to turn off.
response is the one key the request path never reads:
export const oneUser = {
params: UserIndex,
response: { 200: SanitizedUser, 404: NotFound },
} as const satisfies RouteSchemas;The same Standard Schema contract as the request side, so
@dunx/openapi hoists a named response schema into
components/schemas exactly as it hoists a body.
It is checked at compile time, not at runtime. Validating every response body would be a per-request cost paid for a documentation feature. The verb decorator holds the handler's return type to the entry for the route's success status instead, so this does not compile:
@Get('/:id', oneUser)
one({ params }: Input<typeof oneUser>): { id: number } {
// ^ TS1241: property 'name' is missing
}Only the success status is checked, because a 404 is sent by throwing an
HttpError, not by returning. A Response is always allowed. A readonly User[]
satisfies a schema that infers User[]. Returning nothing sends a 204, so it fails
against a declared 200 body.
response does not appear in Input<O>: nothing about it reaches the handler.
Write the options object next to the schemas and hand the same value to both the decorator and the annotation:
import type { RouteSchemas } from '@dunx/http';
import { z } from 'zod';
export const UserIndex = z.object({ id: z.coerce.number().int().min(1) });
export const ListUsers = z.object({
q: z.string().min(1).optional(),
limit: z.coerce.number().int().min(1).max(50).default(10),
});
export const oneUser = { params: UserIndex } as const satisfies RouteSchemas;
export const listUsers = { query: ListUsers } as const satisfies RouteSchemas;satisfies RouteSchemas checks the object without widening it, so the pair is
written that way rather than with a type annotation; see
the one annotation to avoid.
as const keeps status a literal.
Path params
Bun's router matches /users/:id and puts the segment on req.params. Declaring
a params schema validates that object and replaces it with the schema's output:
@Get('/:id', oneUser)
async one({ params }: Input<typeof oneUser>): Promise<User> {
const user = await this.users.find(params.id);
...
}Without a params schema the raw values are still reachable at req.params. The
framework does not hide them; it just does not type or check them.
Query strings
Query values are read with URLSearchParams and grouped before validation, so a
repeated key becomes an array:
?tag=a&tag=b -> { tag: ['a', 'b'] }
?tag=a -> { tag: 'a' }No value is dropped. If a field takes one value, declare it as a single value in the schema, and a repeated key gets a 400.
The query string is sliced out of req.url by hand rather than through
new URL(req.url). Constructing a URL resolves scheme, host, port, path and
fragment to hand back one searchParams object, measuring about 1,040 ns of the
roughly 1,520 ns a three-pair query route cost. That is more than the entire body
reader.
The fragment is still stripped, though a client is not supposed to send one, so a hostile request target cannot change what a schema sees.
Bodies
The content type decides the parser:
content-type | parsed with |
|---|---|
application/json, anything +json | req.json() |
application/x-www-form-urlencoded | URLSearchParams, grouped |
multipart/form-data | req.formData(), grouped |
text/* | req.text() |
| anything else | 415, before the schema is consulted |
No content-type header at all reads as JSON. fetch omits the header for a
bodyless request, and answering 415 there would be useless because the schema is
about to reject undefined with a better message anyway.
Coercion
Path params and query values arrive as strings, always. HTTP has no other representation for them. Coercion belongs to the schema, and with zod it is one call:
export const UserIndex = z.object({ id: z.coerce.number().int().min(1) });z.coerce.number() turns "42" into 42 before .int().min(1) runs. In the
handler, params.id is a number at runtime and in the type, because
Input<> uses the schema's output type:
@Get('/:id', oneUser)
async one({ params }: Input<typeof oneUser>): Promise<User> {
// Already a number. The params schema coerced it before this ran.
const user = await this.users.find(params.id);
if (user === null) {
throw new HttpError(HttpStatusCode.NOT_FOUND, `No user ${params.id}`);
}
return user;
}The same applies to query defaults. limit: z.coerce.number().int().min(1).max(50).default(10)
means the handler sees a number whether or not the caller sent ?limit=, and
"999" is a 400 rather than a page size nobody intended.
This is one of the two reasons the framework does not try to be clever about
types on its own. The other is that .default() changes the type as well as the
value, and only the validator knows that.
Why Input must be written out
The annotation on the handler parameter carries the type the schema infers. It is not optional, and not a wart the framework could remove if it tried.
A TC39 standard method decorator is
(value: V, context: ClassMethodDecoratorContext) => V | void. It receives the
method and can reject one whose type does not match, but there is no mechanism
in the proposal for a decorator to contextually type an unannotated parameter.
This was measured with tsc rather than assumed:
| Handler | Result |
|---|---|
| annotated correctly | compiles |
| unannotated parameter | TS7006: Parameter 'input' implicitly has an 'any' type |
| unannotated destructured | TS7031: Binding element 'body' implicitly has an 'any' type |
| annotated with the wrong type | TS1241 + TS1270, naming the mismatched property |
The guarantee is therefore a check rather than an inference. Get the annotation
wrong and the compiler names the property that does not line up; leave it off and
strict mode rejects the implicit any. It is never silently wrong.
The saving grace is that no type is written twice. Input<O> is a type-level
function over the options object:
export type Input<O extends RouteSchemas> = {
readonly req: BunRequest;
} & (O extends { body: infer B }
? { readonly body: InferOutput<B> }
: unknown) &
(O extends { query: infer Q }
? { readonly query: InferOutput<Q> }
: unknown) &
(O extends { params: infer P }
? { readonly params: InferOutput<P> }
: unknown);Every field type still comes from the schema. Input<typeof createUser> is
{ req: BunRequest; body: { name: string } } because CreateUser says so, and
changing the schema changes the handler's type with no second edit.
A route with no schemas at all still gets the request:
@Get('/whoami')
whoami({ req }: Input<RouteSchemas>): { ip: string | undefined } {
return { ip: this.address.of(req) };
}Input<O> reads the options object's declared type, so the one thing you must
not do is annotate that constant as RouteSchemas:
// Wrong. RouteSchemas.body is optional, so Input<typeof createNote> degrades to
// bare { req } and body is a compile error at every handler.
const createNote: RouteSchemas = { body: CreateNote };
// Right. `satisfies` checks the shape without replacing the type.
const createNote = { body: CreateNote } as const satisfies RouteSchemas;Without as const the body type is kept, but status widens to number.
Options written inline in the decorator need neither, because the decorator infers
their exact type.
InferOutput<S> is exported separately for the times a service signature needs
the same type: InferOutput<typeof CreateUser> is { name: string }.
Related reading: Controllers covers the decorators
themselves and how a return value becomes a Response.
What a validation failure produces
A rejected schema throws ValidationError, which is an HttpError with status
400 carrying every issue the validator reported. The default error mapper turns it
into:
{
"error": "Invalid body",
"status": 400,
"issues": [{ "message": "<the validator's own message>", "path": "name" }]
}The message is whatever the validator produced, passed through unaltered: dunx does not rewrite, translate or truncate it, because the library that rejected the value is the one that knows why.
Three properties of that shape:
errornames the source:Invalid body,Invalid queryorInvalid params.- The issues are included in the response. This is the only place dunx sends request details back to the caller, since they describe the caller's own input.
pathis flattened to dots, and absent when the root itself failed. Standard Schema lets a vendor report a path as bare keys (zod) or as{ key }objects (Valibot); both are normalised here to a single string.
Two other failures are not validation failures and do not carry issues:
- A body the caller mangled is
400 Malformed application/json body. The parse rejected, so there was never a value to validate. - An undeclared content type is
415 Unsupported content type "application/xml". Declared bodies accept application/json, application/x-www-form-urlencoded, multipart/form-data or text/*.
Replace all of it by passing onError to HttpFactory.create; see
Middleware and guards.
Vendor-specific features sit behind a vendor check
Standard Schema only covers validation, so @dunx/openapi converts schemas per
library (see the vendor check). It reads the
one vendor detail the interface includes:
export const vendorOf = (schema: StandardSchemaV1): string =>
schema['~standard'].vendor;If the vendor is zod, the package dynamically imports zod and calls
z.toJSONSchema(schema, { io: 'input', unrepresentable: 'any' }). If it is
anything else, the schema is documented as permissive and a warning is recorded:
UsersController_createBody: no JSON Schema conversion for Standard Schema vendor
"valibot". Standard Schema validates; it does not describe. The schema is
documented as permissive.Warnings are readable off app.get(OpenApiExplorer).warnings straight after
HttpFactory.create(), so a degraded document shows up at boot rather than when
somebody notices an empty request body in the explorer.
zod is an optional peerDependency of @dunx/openapi. A consumer on Valibot
never loads it, and a consumer without it installed gets warnings rather than a
module-resolution crash. Details in OpenAPI.
The rule generalises: validation stays library-agnostic because a standard exists;
anything the standard does not cover is gated on ~standard.vendor and degrades
loudly.
What validation costs
Reading a body costs about three times what validating it costs, and zod, Valibot and ArkType are within noise of each other, so pick a validator on API and error quality. The measurements are in Cost of validation.
Sharp edges
- Never annotate the options constant as
: RouteSchemas. The handler'sinputthen becomes{ req }.satisfies RouteSchemasis what catches a typo in a key name without re-widening the object. - A
paramsschema replacesreq.paramson the input object only.req.paramsis still the raw string record. - The framework validates input and never output. A handler's return value is
serialised as it is,
responseschemas included: they document the answer for@dunx/openapiand nothing checks them at runtime. If a response shape matters, the annotation on the handler is what enforces it. statuson the options object changes the success status only. Errors still come from the thrownHttpErroror from the mapper.- A schema that is not an object is fine for
bodyand wrong forquery/params. A query string is a set of named parameters, so a non-object schema there documents nothing and@dunx/openapiwarns about it. - Request-body logging is off by default and reading it means
req.clone().text(), a second buffered copy of every payload. Turning both body options on costs roughly two thirds of the throughput on thevalidatescenario, and the request body is the field most likely to contain a password. See Middleware and guards.