Heximon Logo
Essentials

Validation & DTOs

Request bodies, query, path params, and headers are validated automatically at dispatch — the handler receives typed data without calling an accessor. Declare the shape once with SchemaObject and any Standard Schema validator.

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.

src/products/product.schema.ts
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:

AccessorReadsNotes
readValidatedBody()the request bodydispatch pre-validates; returns cache hit
getValidatedQuery()the query stringdispatch pre-validates; returns cache hit
getValidatedPathParams()the :segments of the pathdispatch pre-validates; returns cache hit
getValidatedHeaders()the request headersdispatch 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:

WireParsed
?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:

src/webhooks/webhook.controller.ts
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.

src/products/product.api.ts
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.

Constructing a DTO is synchronous; validating a request is not.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 BlobStore port 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 / pathParams in 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 SchemaObject in a ValueObject to 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-data upload validated by .files(...) and streamed into a swappable BlobStore.
Copyright © 2026