Contracts

A contract is the single source of truth for an API endpoint. It describes the HTTP method, path, parameters, response shape, and error cases — all in TypeScript.

Contracts live at @beignet/core/contracts. Beignet intentionally avoids a root @beignet/core entrypoint so imports name the framework area they depend on.

Contract groups

Use defineContractGroup to define a group of related contracts. Groups can share configuration like metadata and shared response schemas.

import {
  defineContract,
  defineContractGroup,
  defineQueryTransport,
  query,
} from "@beignet/core/contracts";
import { z } from "zod";

const TodoSchema = z.object({
  id: z.string(),
  title: z.string(),
  completed: z.boolean(),
});

const CreateTodoSchema = z.object({
  title: z.string().min(1),
  completed: z.boolean().optional(),
});

const todos = defineContractGroup()
  .namespace("todos")
  .prefix("/api/todos")
  .meta({ auth: "required" })
  .headers(z.object({
    authorization: z.string().startsWith("Bearer "),
  }));

The namespace is the resource identity for the group. It is used for OpenAPI tags, generated contract names, and React Query cache-key grouping. The prefix is composed into each contract path. Metadata and shared request header schemas are inherited by all contracts in the group.

Defining contracts

Chain methods to describe the endpoint shape.

Most Beignet APIs accept the contract builder directly. Use .config, .schema, .metadata, or .responseSchemas only when you are writing integration code, tests, generators, or advanced introspection.

GET with path parameters

export const getTodo = todos
  .get("/:id")
  .pathParams(z.object({ id: z.string() }))
  .responses({ 200: TodoSchema });

The client and OpenAPI generator can infer required path argument keys from literal path templates. Add .pathParams(...) when you want runtime validation, coercion, richer OpenAPI schemas, or parameter descriptions.

Beignet contract paths intentionally support only concrete segments and single-segment params such as :id and [id]. Catch-all framework route files such as app/api/[[...path]]/route.ts are adapter glue for exposing createApiRoute(getServer); they do not mean individual contracts should use catch-all patterns such as /files/[...path]. Keep catch-all or prefix dispatch in the host adapter and keep contracts on explicit resource paths.

Request bodies are supported for POST, PUT, and PATCH contracts only.

Request headers

export const getProtectedTodo = todos
  .get("/:id")
  .pathParams(z.object({ id: z.string() }))
  .headers(z.object({
    "x-api-version": z.literal("2026-01-01").optional(),
  }))
  .responses({ 200: TodoSchema });

Use .headers(...) for request headers that are part of the endpoint contract. Declare header keys in lowercase; server and client runtime matching is case-insensitive.

GET with query parameters

const listTodosQueryTransport = defineQueryTransport({
  completed: query.boolean(),
  limit: query.integer(),
  offset: query.integer(),
  tags: query.array(query.string()),
  filter: query.deepObject({ owner: query.string() }),
});

export const listTodos = todos
  .get("/")
  .query(
    z.object({
      completed: z.boolean().optional(),
      limit: z.number().int().min(1).max(100).optional(),
      offset: z.number().int().min(0).optional(),
      tags: z.array(z.string()).optional(),
      filter: z.object({ owner: z.string().optional() }).optional(),
    }),
    listTodosQueryTransport,
  )
  .responses({ 200: z.object({
    items: z.array(TodoSchema),
    page: z.object({
      kind: z.literal("offset"),
      limit: z.number(),
      offset: z.number(),
      total: z.number(),
      hasMore: z.boolean(),
    }),
  }) });

The Standard Schema and query transport have separate jobs. The schema defines the logical input, defaults, validation, and transforms. The transport defines how each field is encoded in a URL. When a schema exposes a concrete input type, Beignet checks its field names and value types against the transport at compile time. The same transport then drives client encoding, server decoding, and OpenAPI serialization.

Primitive fields use ordinary form values, scalar arrays repeat the parameter name, and one-level objects use deepObject bracket names. The example value { tags: ["bug", "urgent"], filter: { owner: "user_1" } } becomes ?tags=bug&tags=urgent&filter[owner]=user_1. Use query.string(), query.number(), query.integer() for JavaScript safe integers, query.boolean(), query.dateTime(), or query.date() for scalar values. query.dateTime() carries an RFC 3339 string; query.date() carries a JavaScript Date and encodes it as RFC 3339. Arrays may contain scalars, and deepObject values must be flat. Unsupported nesting, repeated scalar values, and undeclared object fields fail deterministically.

Empty arrays and objects are omitted by default because OpenAPI form encoding cannot distinguish them from absent parameters. Set { empty: "preserve" } on query.array(...) or query.deepObject(...) only when Beignet typed clients must preserve the distinction. That option uses a versioned Beignet extension; ordinary OpenAPI clients still treat empty and omitted collections as the same value.

The typed client serializes logical query input values. When validateInput: true is enabled, it also validates that input without using a schema transform's output as the wire value. The server decodes the URL once and then runs the Standard Schema, so route handlers receive schema outputs after defaults and transforms. Do not use a schema transform to define an HTTP wire format; declare that format in the query transport instead.

POST with a request body

export const createTodo = todos
  .post("/")
  .body(CreateTodoSchema)
  .responses({ 201: TodoSchema });

PATCH with path and body

export const updateTodo = todos
  .patch("/:id")
  .pathParams(z.object({ id: z.string() }))
  .body(z.object({
    title: z.string().optional(),
    completed: z.boolean().optional(),
  }))
  .responses({ 200: TodoSchema });

DELETE

export const deleteTodo = todos
  .delete("/:id")
  .pathParams(z.object({ id: z.string() }))
  .responses({ 204: null });

Use null for void responses like 204 No Content.

Auto-generated names

If you do not pass a custom name, Beignet generates one from the HTTP method and full path.

defineContract({ method: "GET", path: "/users/:id" }).name;
// "getUsersById"

defineContract({ method: "POST", path: "/api/todos" }).name;
// "createTodos"

The generated name ignores a leading /api, includes path parameters as By..., and is reused by downstream integrations like React Query and OpenAPI. Pass name explicitly when you want a custom identifier.

Path prefixes

Use .prefix(...) on contract groups to compose shared URL segments once:

const api = defineContractGroup().prefix("/api/v1");

const todos = api
  .namespace("todos")
  .prefix("/todos");

export const listTodos = todos.get("/");
// GET /api/v1/todos

export const getTodo = todos.get("/:id");
// GET /api/v1/todos/:id

Prefixes compose immutably and normalize boundary slashes. namespace() controls resource identity for names, tags, and cache keys; prefix() controls URL paths.

Versioning and deprecation

Use path-prefixed contract groups as the default versioning strategy for an external API. Paths remain explicit in contracts, logs, OpenAPI documents, and gateway rules:

const v1 = defineContractGroup()
  .namespace("legacyTodos")
  .prefix("/api")
  .prefix("/v1/todos")
  .deprecated({
    since: "2026-07-11T00:00:00Z",
    sunset: "2027-01-01T00:00:00Z",
    reason: "Use the current todos collection.",
    replacement: "/api/todos",
    documentation: "https://docs.example.com/migrations/todos-v1",
  });

export const listTodosV1 = v1
  .get("/", "list")
  .responses({ 200: z.array(TodoSchema) });

.deprecated(...) is available on a contract or a group. It requires a UTC ISO 8601 since timestamp and accepts an optional sunset, reason, replacement URI, and absolute documentation URL. Beignet marks the OpenAPI operation deprecated and sends standard Deprecation, Sunset, and deprecation-documentation Link headers while the route remains served.

Header- or media-type-based version negotiation stays app-owned. Use it only when an existing public API already requires that model; Beignet does not hide multiple request or response shapes behind one contract.

Responses and catalog errors

Define success responses with .responses(...). Prefer .errors(...) for expected route-owned business failures.

export const getTodo = todos
  .get("/:id")
  .pathParams(z.object({ id: z.string() }))
  .responses({ 200: TodoSchema })
  .errors({ TodoNotFound: errors.TodoNotFound });

Route-owned error responses can still use any schema you declare with .responses() when you need a custom body shape:

export const importTodos = todos
  .post("/import")
  .body(ImportTodosSchema)
  .responses({
    202: ImportJobSchema,
    422: z.object({
      code: z.literal("IMPORT_INVALID"),
      message: z.string(),
      details: z.object({
        errors: z.array(z.string()),
      }),
    }),
  });

Shared response schemas defined on a contract group are inherited by all contracts. Per-contract responses are merged with group responses.

Any non-empty response map is treated as a response contract. Include successful statuses such as 200 or 201 alongside error statuses; use responses: {} only when you want to skip response validation. For framework-neutral route responses, the server sends each response schema's parsed output, so unknown-key stripping and transforms apply before the body reaches the client.

Catalog errors declared with .errors() use Beignet's standard { code, message, details?, requestId? } envelope automatically. .errors() declarations merge: catalog errors declared on a contract group combine with route-level .errors(...), so each contract carries the union. Later declarations win when the same catalog key is declared twice.

Keep application error identity in the catalog, then declare expected catalog errors on each route:

export const errors = defineErrors({
  TodoNotFound: {
    code: "TODO_NOT_FOUND",
    status: 404,
    message: "Todo not found",
    details: z.object({ id: z.string() }),
  },
});

export const getTodo = todos
  .get("/:id")
  .responses({ 200: TodoSchema })
  .errors({ TodoNotFound: errors.TodoNotFound });

Metadata

Attach metadata to contracts for use in server hooks.

const DataSchema = z.object({
  id: z.string(),
  value: z.string(),
});

const PaymentSchema = z.object({
  amount: z.number().positive(),
  currency: z.string(),
});

const PaymentResultSchema = z.object({
  id: z.string(),
  status: z.enum(["pending", "succeeded", "failed"]),
});

// Authentication
export const getProtectedData = todos
  .get("/protected")
  .meta({ auth: "required" })
  .responses({ 200: DataSchema });

// Rate limiting
export const createTodo = todos
  .post("/")
  .meta({ rateLimit: { max: 10, windowSec: 60 } })
  .body(CreateTodoSchema)
  .responses({ 201: TodoSchema });

// Idempotency
const payments = defineContractGroup().namespace("payments");

export const createPayment = payments
  .post("/api/payments")
  .meta({
    idempotency: {
      required: true,
      header: "idempotency-key",
      scope: "actor-tenant",
      ttlSec: 60 * 60 * 24,
    },
  })
  .body(PaymentSchema)
  .responses({ 201: PaymentResultSchema });

Metadata is available to hooks via contract.metadata; Beignet also ships first-party hooks and helpers for concerns such as rate limiting and idempotency.

Top-level metadata uses last-write-wins semantics. The openapi namespace is the exception: .meta({ openapi: ... }) and .openapi(...) merge operation metadata one field deep, so shared group metadata composes with contract-level documentation. Later values replace matching OpenAPI fields; nested objects and arrays are replaced rather than recursively merged.

Schema libraries

Beignet works with any Standard Schema library for runtime validation in contracts, the server, and the client. OpenAPI includes Zod support by default and accepts custom schema converters and introspectors for other libraries. Opaque path parameter schemas fall back to required string parameters derived from the literal path template. Query schemas remain portable because their HTTP representation comes from defineQueryTransport(...); OpenAPI still needs an introspector to document their requiredness and constraints.

Zod

import { z } from "zod";

const TodoSchema = z.object({
  id: z.string(),
  title: z.string(),
  completed: z.boolean(),
});

Valibot

import * as v from "valibot";

const TodoSchema = v.object({
  id: v.string(),
  title: v.string(),
  completed: v.boolean(),
});

ArkType

import { type } from "arktype";

const TodoSchema = type({
  id: "string",
  title: "string",
  completed: "boolean",
});

Introspection

Contracts expose their path and schemas for runtime inspection.

contract.path              // "/api/todos/:id"
contract.schema.pathParams // Standard Schema or null
contract.schema.query      // Standard Schema or null
contract.schema.body       // Standard Schema or null
contract.schema.responses  // { 200: StandardSchema, 404: StandardSchema, ... }
contract.config.queryTransport // QueryTransport or null