Heximon Logo
Essentials

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.

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.

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

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

MethodWhat 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.

Path params and bodies are checked at the type level..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.

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

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

client.ts
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 with Action, and compare the inline route mode.
  • Validation & DTOs — author the SchemaObject DTOs a route references as body, responses, and pathParams.
  • 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.
Copyright © 2026