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 createContractGroup to create a group of related contracts. Groups can share configuration like metadata and shared response schemas.
import { createContract, createContractGroup } 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 = createContractGroup()
.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.
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
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(),
}))
.responses({ 200: z.object({
todos: z.array(TodoSchema),
total: z.number(),
offset: z.number(),
}) });
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.
createContract({ method: "GET", path: "/users/:id" }).name;
// "getUsersById"
createContract({ 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 = createContractGroup().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.
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.
Catalog errors declared with .errors() use Beignet's standard { code, message, details?, requestId? } envelope automatically.
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 = createContractGroup().namespace("payments");
export const createPayment = payments
.post("/api/payments")
.meta({ idempotency: { enabled: true } })
.body(PaymentSchema)
.responses({ 201: PaymentResultSchema });
Metadata is available to hooks via contract.metadata — you can use it to implement auth checks, rate limiting, or anything else.
Schema libraries
Beignet works with any Standard Schema library for runtime validation in contracts, the server, and the client. OpenAPI generation currently requires Zod schemas.
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, ... }