Schema DTOs
Describe a shape once, get a type and a validator. You write a request or response shape as a class —
class CreateTask extends SchemaObject({ ... }) {} — and that single declaration is simultaneously the
inferred TypeScript type and a usable Standard Schema value. It drops straight
into a route's body, responses, or pathParams, and readValidatedBody() validates against it at the
boundary. No interface to keep in sync with a separate validator — there is only one source of truth.
The per-field validators are any Standard Schema: zod is the default across these docs, and valibot, arktype, and others work identically. Heximon never imports a validator library — it owns the object and array structure and delegates each field to its own validator. That is why the package depends on nothing but the Standard Schema contract: you bring the validator you already like.
Install the package
pnpm add @heximon/schema zod
npm install @heximon/schema zod
yarn add @heximon/schema zod
bun add @heximon/schema zod
The validator is your choice — swap in any Standard-Schema library; these examples use zod, but valibot and
arktype work identically, because @heximon/schema owns only the object/array structure and never imports a
validator itself. The whole surface (SchemaObject, SchemaArray, Schema, SchemaError) is exported from
@heximon/schema; there are no subpaths to remember.
Define a DTO
SchemaObject(shape) composes a record of per-field Standard Schemas into a constructible, validating class.
Each value is a plain validator from your library — here, zod validators:
import { SchemaObject } from "@heximon/schema";
import { z } from "zod";
/** The create-task request body. */
export class CreateTaskBody extends SchemaObject({
title: z.string().min(1),
assigneeId: z.string().min(1),
}) {}
/** The task representation the API returns. */
export class TaskResponse extends SchemaObject({
id: z.string(),
title: z.string(),
status: z.enum(["Todo", "Done"]),
assigneeId: z.string(),
completedAt: z.string().optional(), // accepts undefined → optional, omitted from `required`
}) {}
/** The path parameters of a `:id` route. */
export class TaskIdParams extends SchemaObject({
id: z.string().min(1),
}) {}
A field is optional exactly when its validator accepts undefined synchronously (a zod field ending in
.optional()), so optionality is inferred from the field, never declared twice. That inference is what
drives the required array in the JSON Schema Heximon assembles from these DTOs — the same array OpenAPI
reads.
Use the class as both a value and a type
The returned class is a constructor and a Standard Schema value at the same time, so you can hold it as a
type, new it to validate a value, and pass it anywhere a StandardSchemaV1 is expected:
import { Route } from "@heximon/contract";
import { SchemaObject } from "@heximon/schema";
import { z } from "zod";
class CreateUser extends SchemaObject({
email: z.email(),
name: z.string().min(1),
age: z.number().optional(),
}) {}
const create = Route.post("/").body(CreateUser); // accepted as a Standard Schema value
const user = new CreateUser({ email: "a@b.com", name: "Ann" }); // user: { email, name, age? } — a plain value
new Dto(input) validates the input and either throws a SchemaError carrying the per-field issues or
assigns the validated keys onto the instance. There is no wrapper object to unwrap — the instance is the
validated value.
new Dto(input) runs each field validator and needs the
result on the spot, so a leaf that returns a Promise makes the constructor throw a SchemaError —
"Schema validation must be synchronous when constructing a value." Async-only validators still work through
readValidatedBody() (the validation path awaits pending leaves); only the new Dto(...) shortcut requires
synchronous fields. Keep an async leaf out of any DTO you intend to new directly, and validate it at the HTTP
boundary instead.Derive new DTOs without repeating fields
Every SchemaObject class carries typed derivation operators that rebuild the field record — Heximon never
re-inspects the underlying leaves, it just transforms the record — and they chain. This is how you spell
"the create body, but every field optional" without restating the fields:
class UpdateUser extends CreateUser.partial() {} // every key optional
class EmailOnly extends CreateUser.pick(["email"]) {} // keep only `email`
class WithRole extends CreateUser.extend({ role: z.string() }) {} // add a required field
// chainable:
class Patch extends CreateUser.partial().extend({ revision: z.number() }) {}
SchemaArray(element) is the array counterpart: it wraps an element schema into a validating class whose
instance type is an array of the element's output, re-pathing each element's issues under its index.
import { SchemaArray } from "@heximon/schema";
class TaskListResponse extends SchemaArray(TaskResponse) {} // a validating class over TaskResponse[]
Drop a DTO into a contract
Because each DTO is a Standard Schema value, it slots directly into a contract
route's body, responses, and pathParams — no adapter, no typeof gymnastics:
import { Contract, Route } from "@heximon/contract";
import { SchemaArray } from "@heximon/schema";
import { CreateTaskBody, TaskIdParams, TaskResponse } from "./task.dto";
class TaskListResponse extends SchemaArray(TaskResponse) {}
export class TaskApi extends Contract({
prefix: "/api/tasks",
routes: {
list: Route.get("/").responses({ 200: TaskListResponse }),
create: Route.post("/").body(CreateTaskBody).responses({ 201: TaskResponse }),
complete: Route.post("/:id/complete").pathParams(TaskIdParams).responses({ 200: TaskResponse }),
},
}) {}
Validate at the boundary
A contract-bound controller reads validated input through readValidatedBody() and
getValidatedPathParams(). Each checks the request against the route's DTO and hands back the typed output —
your handler only ever sees a value that already passed:
import { type Action, type Controller } from "@heximon/http";
import { TaskApi } from "./task.api";
export class TasksController implements Controller<TaskApi> {
public async create(action: Action<TaskApi, "create">) {
const body = await action.request.readValidatedBody(); // validated against CreateTaskBody
// body: { title: string; assigneeId: string }
return action.respond(201, /* ... */);
}
public async list(action: Action<TaskApi, "list">) {
return action.json(/* ... */);
}
public async complete(action: Action<TaskApi, "complete">) {
const { id } = await action.request.getValidatedPathParams(); // validated against TaskIdParams
return action.respond(200, /* ... */);
}
}
When validation fails, Heximon returns its ValidationError as a 400 application/problem+json response with
the per-field issues attached — the DTO never reaches your handler. That's the point of validating at the
boundary: the inside of your app only deals in shapes it can trust.
Back a value object with a DTO
The same construct-time validation makes a SchemaObject a natural input for a DDD
value object — the value object simply wraps the DTO and inherits its validation:
import { SchemaObject } from "@heximon/schema";
import { z } from "zod";
export class Credentials extends SchemaObject({
email: z.email(),
password: z.string().min(8),
}) {}
// also: class Money extends ValueObject(SchemaObject({ amount: z.number(), currency: z.string() })) {}
Feed OpenAPI for free
The JSON Schema Heximon assembles from a DTO is built from each field's StandardJSONSchemaV1 — the matching
JSON-Schema converter the Standard Schema spec defines, a standard rather than a validator library — so the
same DTOs that validate your routes also describe them in OpenAPI with no extra wiring. Zod v4
carries that converter natively, so a z.email()/z.string().min(1)/… field embeds at full fidelity out of
the box. A field whose validator exposes no such converter renders as a permissive {} by default, but the
object structure and the required array are still emitted, so the document stays accurate where it can be
precise.
Full valibot leaf types with @heximon/schema/valibot
Reach for this subpath only if you chose valibot over zod: plain valibot leaves (v.string(), v.number(),
…) do not carry the Standard-Schema JSON Schema sister-spec, so without extra wiring they degrade to {} in
the generated OpenAPI document. Install the optional peer and use the @heximon/schema/valibot subpath to
enable full-fidelity output:
pnpm add @valibot/to-json-schema
npm install @valibot/to-json-schema
yarn add @valibot/to-json-schema
bun add @valibot/to-json-schema
Then wire the converter when constructing the OpenAPI generator — it chains valibot-specific conversion before the standard sister-spec path:
import { valibotJsonSchema, registerValibotLeafFallback } from "@heximon/schema/valibot";
import { OpenApiGenerator, StandardSchemaConverter } from "@heximon/openapi";
// Optional: register the process-wide leaf fallback for SchemaObject field leaves.
registerValibotLeafFallback();
new OpenApiGenerator({
contracts: [MyApi],
converter: (schema, direction) =>
valibotJsonSchema(schema, direction) ?? StandardSchemaConverter.convert(schema, direction),
});
valibotJsonSchema guards on ~standard.vendor === "valibot" before invoking @valibot/to-json-schema,
so it is safe to compose with StandardSchemaConverter.convert. Pipe actions that cannot be represented
in JSON Schema (e.g. custom transforms) are silently omitted (errorMode: "ignore") — the converter
returns undefined for any completely unrepresentable schema, falling back to {} rather than throwing.
See also
- Contracts — where DTOs become a route's
body,responses, andpathParams, shared by server and client. - Validation — how
readValidatedBody()turns a failed DTO into a400 application/problem+jsonresponse. - Branded IDs — carry nominal, type-safe identifiers through your DTO fields.
- Domain-Driven Design — wrap a
SchemaObjectin aValueObjectfor a validated, immutable domain value. - OpenAPI + MCP — a contract whose schema DTOs drive both the served routes and the generated OpenAPI document.
- the flagship app —
SchemaObjectDTOs across request bodies, responses, path params, and value-object inputs in a full application.
MCP Server
Derive a Model Context Protocol server from a Contract's route table at runtime with McpServerGenerator — per-route .mcp() opt-in, marked or all selection, in-process tool execution, and stateless Streamable HTTP.
Branded IDs
Nominal entity identifiers with the zero-runtime Branded type and the DI-free Id helper — generate, from, uuid.v7, uuid.v4, $type, and end-to-end id safety.