Heximon Logo
Capabilities

Schema DTOs

Author request and response shapes with SchemaObject and SchemaArray — one class that is both the validated TypeScript type and a Standard Schema value, with partial, pick, omit, and extend derivations.

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

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:

src/tasks/task.dto.ts
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.

Constructing a DTO validates synchronously.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:

src/tasks/task.api.ts
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:

src/tasks/tasks.controller.ts
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:

src/auth/credentials.schema.ts
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

Then wire the converter when constructing the OpenAPI generator — it chains valibot-specific conversion before the standard sister-spec path:

src/docs/docs.module.ts
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, and pathParams, shared by server and client.
  • Validation — how readValidatedBody() turns a failed DTO into a 400 application/problem+json response.
  • Branded IDs — carry nominal, type-safe identifiers through your DTO fields.
  • Domain-Driven Design — wrap a SchemaObject in a ValueObject for a validated, immutable domain value.
  • OpenAPI + MCP — a contract whose schema DTOs drive both the served routes and the generated OpenAPI document.
  • the flagship appSchemaObject DTOs across request bodies, responses, path params, and value-object inputs in a full application.
Copyright © 2026