Contracts
An API has three audiences that must agree on its shape — the server that implements it, the client that
calls it, the spec that documents it. Keep that shape in three places and they drift. A Contract holds it
in one place: a route table you author once, then bind everywhere.
import { Contract, Route } from "@heximon/contract";
import { createProduct, product, productList, productParams } from "./product.schema";
export class ProductsApi extends Contract({
prefix: "/api/products",
routes: {
list: Route.get("/").summary("List products").responses({ 200: productList }),
find: Route.get("/:sku").pathParams(productParams).responses({ 200: product }),
create: Route.post("/").body(createProduct).responses({ 201: product }),
},
}) {}
That one class is consumed three ways: a backend controller implements Controller<ProductsApi>,
a typed REST client calls it, and the OpenAPI generator reads it. It carries no server or client code, so
it's safe to import from the frontend — the seam between client and server can't depend on either side.
Because it's a class, ProductsApi is both a value (its static routes/prefix) and a type (no
typeof).
Define a contract
Contract({...}) takes a prefix, a routes table, and optional contract-level responses, and returns
a base class you extend. Each routes key becomes a controller handler name — name them as you'd name
methods.
import { Contract, Route } from "@heximon/contract";
import { createProduct, product, productList, productParams } from "./product.schema";
export class ProductsApi extends Contract({
prefix: "/api/products",
routes: {
list: Route.get("/").summary("List products").responses({ 200: productList }),
find: Route.get("/:sku")
.pathParams(productParams)
.scopes("products.read")
.responses({ 200: product }),
create: Route.post("/")
.body(createProduct)
.scopes("products.write")
.responses({ 201: product }),
},
}) {}
Each route reads back as a finished descriptor — plain values the client and OpenAPI generator iterate.
Build a route
Route.get(path) and its sibling verbs (.post, .put, .patch, .delete, .head, .options) start a
builder you chain. Each method attaches one piece of the route's shape.
import { Route } from "@heximon/contract";
import { createProduct, product, productList, productParams } from "./product.schema";
const list = Route.get("/").summary("List products").responses({ 200: productList });
const find = Route.get("/:sku")
.pathParams(productParams)
.scopes("products.read")
.responses({ 200: product });
const create = Route.post("/")
.body(createProduct)
.scopes("products.write")
.responses({ 201: product });
Every chaining method widens the route's type, so the contract knows each route's exact inputs and outputs:
| Method | What it declares |
|---|---|
.pathParams(schema) | The :segment schema — its output must cover every segment in the path |
.query(schema) | The query-string schema |
.headers(schema) | The request-headers schema |
.body(schema) | The request-body schema — unavailable on GET/HEAD |
.responses(schemas) | Status-keyed response schemas ({ 200: …, 404: … }) |
.responseContentType(type) · .download() · .html() | The non-JSON 2xx body wire type (binary / text); the client decodes it to a Blob/string and skips validation, declared errors stay JSON |
.scopes(...names) | Authorization scopes the auth guard enforces |
.cors(options) | CORS handling — true for permissive, or a CorsOptions object; registers an implicit OPTIONS row (and HEAD on GET) |
.cache(options) | Cache directives for the response — maxAge/staleMaxAge/swr; emitted as cache headers by the HTTP layer |
.summary(text) | A short one-liner (OpenAPI summary) |
.description(text) | Extended prose (OpenAPI description); Markdown is supported in renderers |
.deprecated() | Mark the route as deprecated — the OpenAPI generator emits deprecated: true on the operation |
The schemas are any Standard Schema value — a SchemaObject from
@heximon/schema, or a raw Zod / Valibot / ArkType schema. The contract doesn't pin a validator; it
holds whatever you author for the server and client to check against.
.pathParams(schema) requires the schema's output
to cover every :segment in the path, and .body isn't callable on a GET/HEAD route. Forget the :sku
param or attach a body to a GET, and pnpm check flags it — the contract can't describe an unservable route.Author routes with less ceremony
The fluent chain is the common form; two shorthands save repetition.
Route.define(path, { method, ... }) puts the whole route in one flat object — same descriptor, and path
stays positional so the path-param check still applies. The flat form accepts all the same metadata fields
as the fluent chain: pathParams, query, body, headers, responses, scopes, summary,
description, cors, cache, and deprecated.
import { Route } from "@heximon/contract";
import { productParams, product } from "./product.schema";
const find = Route.define("/:sku", {
method: "GET",
pathParams: productParams,
summary: "Find a product",
description: "Returns the product matching the given SKU. Returns 404 when not found.",
cors: { origin: "*" },
responses: { 200: product },
});
Route.path(path) types a path's :params once, then reuses them across verbs through its verb getters
— so a resource with several methods on one path declares its params once:
import { Route } from "@heximon/contract";
import { productParams, product } from "./product.schema";
const bySku = Route.path("/:sku").pathParams(productParams);
const find = bySku.get.responses({ 200: product });
const remove = bySku.delete.responses({ 204: product });
Share responses across routes
Most routes answer the same way on failure — a 400 for bad input, a 401 for a missing token. Contract-level
responses fold into every route, so each route only declares what's unique to it.
import { Contract, Route } from "@heximon/contract";
import { SchemaObject } from "@heximon/schema";
import * as v from "valibot";
// Error shapes are plain Standard-Schema DTOs, authored exactly like success shapes.
class ValidationError extends SchemaObject({ message: v.string() }) {}
class UnauthorizedError extends SchemaObject({ message: v.string() }) {}
class NotFoundError extends SchemaObject({ message: v.string() }) {}
export class ProductsApi extends Contract({
prefix: "/api/products",
responses: { 400: ValidationError, 401: UnauthorizedError }, // merged into every route
routes: {
list: Route.get("/").responses({ 200: productList }),
find: Route.get("/:sku").pathParams(productParams).responses({ 200: product, 404: NotFoundError }),
},
}) {}
ProductsApi.routes.find.responses?.[404]; // NotFoundError — the route's own
ProductsApi.routes.find.responses?.[400]; // ValidationError — the shared default, folded in
A route's own status wins on collision — a route-specific 400 overrides the shared one there, while
every other shared status still folds in: terse by default, with per-route control intact.
Bind the contract on the server
A contract-mode controller implements Controller<ProductsApi> — the contract supplies its prefix,
handler names, and per-route types. Each handler matches a route key, its action typed off that route with
Action<ProductsApi, "key">.
import type { Action, Controller } from "@heximon/http";
import { ProductsApi } from "./product.api";
import { ProductsRepository } from "./products.repository";
export class ProductsController implements Controller<ProductsApi> {
public constructor(private readonly products: ProductsRepository) {}
public async list(action: Action<ProductsApi, "list">) {
return this.products.list();
}
public async find(action: Action<ProductsApi, "find">) {
const { sku } = await action.request.getValidatedPathParams();
return this.products.getBySku(sku);
}
public async create(action: Action<ProductsApi, "create">) {
const body = await action.request.readValidatedBody();
return action.respond(201, await this.products.create(body));
}
}
Because the types come from the contract, the implements Controller<ProductsApi> clause checks that
every route has a handler and that each return type satisfies its responses. Change a response schema
and the controller lights up where it no longer matches — spec and implementation can't quietly diverge.
The controllers page covers both modes in depth, including the config form that adds
controller-level middlewares (implements Controller<{ contract: ProductsApi; middlewares: [Auth] }>).
Call it from the same value
The contract that types the server is also the client — construct it with a transport and call its
.client. The request body, response status, and response shape are all known with nothing to annotate.
import { ClientTransport } from "@heximon/client";
import { ProductsApi } from "./products/product.api";
const api = new ProductsApi(ClientTransport.fetch({ baseUrl: "https://shop.example.com" }));
const product = await api.client.create({
body: { sku: "ABC-1", name: "Widget", priceInCents: 1999 },
});
// ^? { sku: string; name: string; priceInCents: number } — the 2xx body (throws on non-2xx)
Edit the contract, and every handler and client call that no longer matches surfaces as a type error — the point of one source of truth: the type system, not a code review, catches drift. See the REST client and OpenAPI pages for the two consumers in full.
See also
- Controllers — bind a contract with
implements Controller<SomeApi>, type each handler withAction, and compare the inline route mode. - Validation & DTOs — author the
SchemaObjectDTOs a route references asbody,responses, andpathParams. - REST client — call a contract by constructing it with a transport (
new SomeApi(...)). - OpenAPI — generate an OpenAPI document from the same contract at runtime.
- OpenAPI + MCP — a products contract bound by a contract-mode controller and turned into a served Swagger / Scalar spec from the one value.
- the flagship app — a scoped task contract driving a guarded controller, CQRS handlers, and a typed client end-to-end.
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.
REST Client
Call a shared Contract from the frontend by constructing it with a transport — ClientTransport.fetch, ClientTransport.internal, and ClientTransport.mock, throwing 2xx-body callers, raw() for the full status union, request/response interceptors, and vue-query bindings.