Validation

Edit docs/guide/06-validation.md on GitHub

A route declares the shape of what it accepts on the decorator itself. The framework parses, validates and types the request before the handler runs.

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

TS
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:

TS
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:

TS
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:

TS
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:

TS
@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:

TS
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:

TS
@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-typeparsed with
application/json, anything +jsonreq.json()
application/x-www-form-urlencodedURLSearchParams, grouped
multipart/form-datareq.formData(), grouped
text/*req.text()
anything else415, 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:

TS
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:

TS
@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:

HandlerResult
annotated correctlycompiles
unannotated parameterTS7006: Parameter 'input' implicitly has an 'any' type
unannotated destructuredTS7031: Binding element 'body' implicitly has an 'any' type
annotated with the wrong typeTS1241 + 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:

TS
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:

TS
@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:

TS
// 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:

JSON
{
  "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:

  • error names the source: Invalid body, Invalid query or Invalid 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.
  • path is 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:

TS
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's input then becomes { req }. satisfies RouteSchemas is what catches a typo in a key name without re-widening the object.
  • A params schema replaces req.params on the input object only. req.params is still the raw string record.
  • The framework validates input and never output. A handler's return value is serialised as it is, response schemas included: they document the answer for @dunx/openapi and nothing checks them at runtime. If a response shape matters, the annotation on the handler is what enforces it.
  • status on the options object changes the success status only. Errors still come from the thrown HttpError or from the mapper.
  • A schema that is not an object is fine for body and wrong for query/params. A query string is a set of named parameters, so a non-object schema there documents nothing and @dunx/openapi warns 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 the validate scenario, and the request body is the field most likely to contain a password. See Middleware and guards.