Validation & DTOs
Declare a schema on a route and validation runs automatically — before the middleware chain, before the
handler body. The handler receives already-validated, typed data. Anything that fails the check is
rejected before your code runs; the caller gets a 400 application/problem+json response.
You describe each shape as a DTO class with SchemaObject, built over per-field validators from any
Standard Schema library. Zod is the default, but Valibot and ArkType work
identically — Heximon owns the boundary plumbing and delegates each field to the validator you bring.
Author a DTO with SchemaObject
SchemaObject(shape) turns a record of per-field validators into a class that is, at the same time, the
inferred TypeScript type and a usable Standard-Schema value. That dual nature is the whole point:
the one class you write is what a route validates against, what a handler reads back, and what the type
checker holds you to — no separate interface to keep in sync.
import { SchemaObject } from "@heximon/schema";
import { z } from "zod";
/** The POST /api/products request body — no server-managed fields. */
export class CreateProduct extends SchemaObject({
sku: z.string().min(1),
name: z.string().min(1),
priceInCents: z.number().int().positive(),
}) {}
/** A single product as the API returns it. */
export class Product extends SchemaObject({
sku: z.string(),
name: z.string(),
priceInCents: z.number().int(),
}) {}
@heximon/schema itself imports no validator library — zod is only there because that's what these docs
default to. Swap the import { z } from "zod" line for valibot or arktype and nothing else changes,
because the package depends only on the Standard Schema contract every one of them implements.
Validated by default at dispatch
When a route declares a schema, the framework validates the matching input at dispatch time — before
the middleware chain runs, before the handler body executes. The validated typed value is attached to the
request, and any accessor call in the handler returns it immediately from the cache. Invalid input throws
a RequestValidationError, which the built-in filter maps to a 400 application/problem+json response
before the handler runs.
// Inside a contract-mode controller (`implements Controller<ProductsApi>`):
public async create(action: Action<ProductsApi, "create">) {
const body = await action.request.readValidatedBody(); // typed CreateProduct — already validated at dispatch
return action.respond(201, await this.products.create(body));
}
The readValidatedBody() call above is an accessor returning the dispatch-pre-validated value from the
cache. A handler that never calls the accessor still gets the protection — invalid input is rejected
before the handler body runs.
The four accessor methods remain available as escape-hatches for programmatic access or to pass an explicit override schema:
| Accessor | Reads | Notes |
|---|---|---|
readValidatedBody() | the request body | dispatch pre-validates; returns cache hit |
getValidatedQuery() | the query string | dispatch pre-validates; returns cache hit |
getValidatedPathParams() | the :segments of the path | dispatch pre-validates; returns cache hit |
getValidatedHeaders() | the request headers | dispatch pre-validates; returns cache hit |
Pass a schema explicitly — readValidatedBody(SomeDto) — to bypass the cache and re-validate with a
different schema. The return type follows from the argument instead.
Structured query parameters
A query string is flat text, but a query schema can declare arrays and nested objects. The server expands the bracket notation a client serializes them with back into structure before validation — so a query schema with an array or nested object round-trips by default:
| Wire | Parsed |
|---|---|
?tags[0]=a&tags[1]=b (or ?tags[]=a&tags[]=b) | { tags: ["a", "b"] } |
?filter[status]=active&filter[city]=NYC | { filter: { status: "active", city: "NYC" } } |
?q=boots&page=2 | { q: "boots", page: "2" } |
This is the exact scheme the @heximon/contract self-client emits, so a structured query a client sends
arrives as the arrays/objects the route's query schema expects:
import { Route } from "@heximon/contract";
import { z } from "zod";
// ?q=boots&tags[0]=a&tags[1]=b&filter[status]=active → { q, tags: [...], filter: { status } }
export const search = Route.get("/search")
.query(
z.object({
q: z.string().optional(),
tags: z.array(z.string()).optional(),
filter: z.object({ status: z.string(), city: z.string() }).optional(),
}),
)
.responses({ 200: z.object({ ok: z.boolean() }) });
The expansion reads only the key syntax — it never inspects the schema, and it does not comma-split
values (a "a,b" query value stays the string "a,b"; use bracket notation or repeated keys for an array).
Path parameters and headers are likewise passed through as-is — the contract types each as a string.
Raw-body opt-out
Webhook-signature or streaming routes must receive the body stream unconsumed. Declare .rawBody() on
the route builder to opt out of dispatch-time body buffering for that route:
import type { Controller, Post } from "@heximon/http";
import { Route } from "@heximon/contract";
import { createHmac } from "node:crypto";
// `.rawBody()` opts the route out of dispatch-time body buffering (it works on a `Contract` route too).
export const webhookRoute = Route.post("/webhook").rawBody();
export class WebhookController implements Controller<"/webhook"> {
public async receive(action: Post<"/", { rawBody: true }>): Promise<{ ok: boolean }> {
// action.request.bodyUsed is false — the dispatcher did not consume the stream, so the raw
// bytes are still available to verify the signature.
const raw = await action.request.text();
const provided = action.request.headers.get("x-signature") ?? "";
const expected = createHmac("sha256", process.env["WEBHOOK_SECRET"] ?? "").update(raw).digest("hex");
return { ok: provided === expected };
}
}
.rawBody() is type-gated off GET/HEAD (those carry no request body). The readValidated* escape-hatch
accessors still work on raw-body routes — they fall through to the live stream on first call — but there
is no automatic 400 when the accessor is never called, because the dispatcher did not run the schema.
Reference a DTO from the route
A DTO is a Standard-Schema value, so it drops straight into a contract route's body, responses, or
pathParams. The route is the single declaration the controller binds to, the OpenAPI document reads,
and the validated accessors inherit — author the shape once, reference it everywhere.
import { Contract, Route } from "@heximon/contract";
import { SchemaArray } from "@heximon/schema";
import { CreateProduct, Product } from "./product.schema";
class ProductList extends SchemaArray(Product) {} // a validating class over Product[]
export class ProductsApi extends Contract({
prefix: "/api/products",
routes: {
list: Route.get("/").responses({ 200: ProductList }),
create: Route.post("/").body(CreateProduct).responses({ 201: Product }),
},
}) {}
SchemaArray(element) wraps a single element schema into a validating class for an array of it,
re-reporting each element's errors under its index — the array veneer to SchemaObject's object veneer.
If you author routes inline instead of through a contract, the same DTO goes in the action type as the bare
class reference — typeof CreateProduct resolves identically, if you'd rather spell it as a type — and the
accessor still takes no argument:
public async create(action: Post<"/", { body: CreateProduct }>): Promise<Product> {
const body = await action.request.readValidatedBody(); // inherits CreateProduct
return this.products.create(body);
}
Upload files (multipart)
A route can also declare file fields with .files({...}), which marks it multipart/form-data and
validates each field's maxBytes/accept alongside the rest of the .body(...) schema. See
Upload a file for the full route-to-BlobStore walkthrough and
Blob Storage for the streaming port and driver swap behind it.
Construct a DTO directly
Because a DTO is a class, new CreateProduct({ ... }) validates a value synchronously — handy in a
service, a test, or a seed script where there's no request. You get a plain object of the inferred type
back, or a SchemaError carrying the failing issues.
const product = new CreateProduct({ sku: "abc", name: "Widget", priceInCents: 1500 });
// product: { sku: string; name: string; priceInCents: number } — a plain value
A field counts as optional only when its validator accepts undefined synchronously — a zod field
ending in .optional(). That single rule is what populates the required list in the JSON Schema the
OpenAPI layer emits, so optionality is read off the validator, never declared twice.
new Dto(input) runs the field
validators inline, so a validator that returns a Promise can't be resolved in time — the constructor
throws a SchemaError: "Schema validation must be synchronous when constructing a value.". The
request-boundary path (readValidatedBody and friends) awaits each field, so an async validator works
fine there. The fix: keep async refinements out of DTOs you new by hand, or only validate them at the
boundary.Derive a DTO instead of repeating it
A create body, an update body, and a public projection are usually one shape minus or plus a field.
Rather than write three SchemaObjects, derive them — every DTO class exposes partial, pick,
omit, and extend, each rebuilding the field record and chainable.
class CreateUser extends SchemaObject({
email: z.email(),
name: z.string().min(1),
age: z.number().optional(),
}) {}
class UpdateUser extends CreateUser.partial() {} // every field optional
class EmailOnly extends CreateUser.pick(["email"]) {} // keep only email
class PublicUser extends CreateUser.omit(["email"]) {} // drop email
class WithRole extends CreateUser.extend({ role: z.string() }) {} // add a field
class Patch extends CreateUser.partial().extend({ revision: z.number() }) {} // chained
These operators rebuild the shape without ever inspecting a field's validator, so the derived class
stays fully introspectable — the OpenAPI emitter sees its real fields, not an opaque blob. For an
engine-specific refinement that does touch the validator (a Zod .refine, a Valibot pipe), reach for
customize; that result is deliberately opaque to extraction, so prefer the four derivations whenever
you want the derived shape to keep showing up in your generated spec.
Validated requests, reviewed responses
Requests are validated and rejected; a handler's response is only reviewed. In development,
reviewResponse logs a warning when a returned body doesn't match the route's declared response schema
for that status — a fast signal that your handler and your contract have drifted. It never throws and is
a no-op in production, because a response that's already on the wire is not something to fail a request
over. That asymmetry is on purpose: you don't trust the client, but you do trust your own code enough to
ship it while you fix the warning.
See also
- Upload a file — the shortest path from a multipart route to a stored blob, with the production swap to a filesystem/S3/R2 driver.
- Blob Storage — the
BlobStoreport a validated file field streams into, plus its memory/filesystem/R2/S3 drivers. - Controllers — declare routes by action-parameter type and read the validated body inside a handler.
- Contracts — reference these DTOs as a route's
body/responses/pathParamsin one shared, frontend-safe value. - REST client — consume the same contract from the frontend, with opt-in response-body validation.
- OpenAPI — each DTO's JSON Schema embeds into the generated spec at full fidelity.
- Domain-Driven Design — wrap a
SchemaObjectin aValueObjectto validate a domain value once at its edge. - OpenAPI + MCP — a contract of validated routes, a contract-mode controller reading validated input, and an e2e test that proves the generated spec matches the served API.
- Storage & blob adapters
— a
multipart/form-dataupload validated by.files(...)and streamed into a swappableBlobStore.
Controllers
Build the HTTP surface with Controllers — routes declared by the handler's action parameter type, the HttpAction dispatcher, middleware, error filters, and contract binding.
Contracts
Describe an HTTP API once with the Contract class and Route builder, then bind the server, the REST client, and OpenAPI to the same route table — Route.get/post, body, responses, pathParams, scopes, shared responses, contract-mode controllers.