@toad-contracts/core
API contracts are shared definitions that live in a shared package and are consumed by both the client and the backend. The contract describes a route (its path, HTTP method, and request/response schemas) and serves as the single source of truth for both sides.
The backend implements the route against the contract. The client uses the same contract to make type-safe requests without duplicating configuration. This keeps documentation, validation, and types in sync across the boundary.
Schemas are any Standard Schema implementation
(valibot, zod, arktype, and others).
The
contract logic only depends on the @standard-schema/spec interface, not on a specific library.
Using valibot? Prefer
@toad-contracts/valibot. It re-exports everything here and addswithObjectKeys, which lets a valibot object schema satisfy the object-key introspection core needs for path-param schemas. See Path mapping below for the underlying mechanism.
Defining contracts
REST routes
A requestPathParamsSchema must expose its object keys so the route path can be built from the
contract (see Path mapping). The Standard Schema interface does not expose keys, so
wrap the schema with your adapter's helper, here withObjectKeys from @toad-contracts/valibot.
Query, header, body, and response schemas need no wrapping.
import { defineApiContract, noBodyResponse } from "@toad-contracts/core";
import { withObjectKeys } from "@toad-contracts/valibot";
import { object, string, pipe, uuid } from "valibot";
// GET with path params
const getUser = defineApiContract({
method: "get",
requestPathParamsSchema: withObjectKeys(object({ userId: pipe(string(), uuid()) })),
pathResolver: ({ userId }) => `/users/${userId}`,
responsesByStatusCode: {
200: object({ id: string(), name: string() }),
},
});
// POST
const createUser = defineApiContract({
method: "post",
pathResolver: () => "/users",
requestBodySchema: object({ name: string() }),
responsesByStatusCode: {
201: object({ id: string(), name: string() }),
},
});
// DELETE with no response body
const deleteUser = defineApiContract({
method: "delete",
requestPathParamsSchema: withObjectKeys(object({ userId: pipe(string(), uuid()) })),
pathResolver: ({ userId }) => `/users/${userId}`,
responsesByStatusCode: {
204: noBodyResponse(),
},
});Non-JSON responses
For responses that are not JSON, three wrappers record the response content-type in the contract
and differ only in the JS type the client materializes the body into:
textResponse(contentType)→string. Convenient for small text payloads (CSV, plain text).blobResponse(contentType)→Blob. Buffered; offers.text(),.arrayBuffer(),.stream().streamResponse(contentType)→ReadableStream<Uint8Array>. Zero buffering; stream large payloads directly, or wrap for convenience vianew Response(body).text()/.blob()/.arrayBuffer().
import {
defineApiContract,
textResponse,
blobResponse,
streamResponse,
} from "@toad-contracts/core";
const exportCsv = defineApiContract({
method: "get",
pathResolver: () => "/export.csv",
responsesByStatusCode: { 200: textResponse("text/csv") },
});
const downloadPhoto = defineApiContract({
method: "get",
pathResolver: () => "/photo.png",
responsesByStatusCode: { 200: blobResponse("image/png") },
});
// large export streamed without buffering the whole body in memory
const streamExport = defineApiContract({
method: "get",
pathResolver: () => "/export-large.csv",
responsesByStatusCode: { 200: streamResponse("text/csv") },
});SSE and dual-mode routes
Use sseResponse() inside responsesByStatusCode to define SSE event schemas. For endpoints that
respond with either JSON or an SSE stream depending on the Accept header, use anyOfResponses()
to declare both options on the same status code.
import { defineApiContract, sseResponse, anyOfResponses } from "@toad-contracts/core";
import { object, string } from "valibot";
// SSE-only
const notifications = defineApiContract({
method: "get",
pathResolver: () => "/notifications/stream",
responsesByStatusCode: {
200: sseResponse({
notification: object({ id: string(), message: string() }),
}),
},
});
// Dual-mode: JSON response or SSE stream depending on Accept header
const chatCompletion = defineApiContract({
method: "post",
pathResolver: () => "/chat/completions",
requestBodySchema: object({ message: string() }),
responsesByStatusCode: {
200: anyOfResponses([
sseResponse({
chunk: object({ delta: string() }),
done: object({ finish_reason: string() }),
}),
object({ text: string() }),
]),
},
});Wildcard and default response keys
In addition to exact status codes, responsesByStatusCode accepts OpenAPI-style range keys
('1xx'–'5xx') and 'default' as fallbacks. Lookup precedence at runtime:
exact code → range key → 'default'.
import { defineApiContract } from "@toad-contracts/core";
import { object, array, string, unknown } from "valibot";
const listItems = defineApiContract({
method: "get",
pathResolver: () => "/items",
responsesByStatusCode: {
"2xx": object({ items: array(string()) }),
"4xx": object({ message: string() }),
},
});
const flexible = defineApiContract({
method: "get",
pathResolver: () => "/data",
responsesByStatusCode: {
200: object({ data: unknown() }),
default: object({ error: string() }),
},
});The '2xx' range key participates in SSE detection and success/error type narrowing exactly like
explicit 2xx codes. 'default' is split into a success half (SuccessfulHttpStatusCode) and a
non-success half in InferSseClientResponse / InferNonSseClientResponse so error narrowing stays
correct regardless of the actual status code.
Path mapping
mapApiContractToPath(contract) turns a contract into an Express/Fastify-style path pattern
("/users/:userId"); describeApiContract(contract) returns "GET /users/:userId". Both are
single-argument.
To build the pattern, core needs the path-param field names. The Standard Schema spec is
validation-only and does not expose an object schema's keys at runtime, so core defines a small
ObjectKeysCarrier interface and requires a requestPathParamsSchema to implement it:
export interface ObjectKeysCarrier {
getObjectKeys: () => readonly string[];
}The dependency is inverted: core depends only on this interface, never on a concrete schema library,
and adapters satisfy it. @toad-contracts/valibot exposes withObjectKeys, which reads valibot's
.entries:
import { mapApiContractToPath, describeApiContract } from "@toad-contracts/core";
import { withObjectKeys } from "@toad-contracts/valibot";
import { object, string } from "valibot";
const getUser = defineApiContract({
method: "get",
requestPathParamsSchema: withObjectKeys(object({ userId: string() })),
pathResolver: ({ userId }) => `/users/${userId}`,
responsesByStatusCode: { 200: object({ id: string() }) },
});
mapApiContractToPath(getUser); // "/users/:userId"
describeApiContract(getUser); // "GET /users/:userId"Without an adapter, implement the interface yourself by attaching getObjectKeys to any Standard
Schema:
const pathParams = Object.assign(object({ userId: string() }), {
getObjectKeys: () => ["userId"],
});Type utilities
InferNonSseSuccessResponses<T>: TypeScript output type of all non-SSE 2xx responses. JSON schemas →StandardSchemaV1.InferOutput<T>,textResponse→string,blobResponse→Blob,streamResponse→ReadableStream<Uint8Array>,ContractNoBody/NoBodyResponse→undefined,sseResponse→never(excluded).anyOfResponsesentries are unpacked before mapping.InferJsonSuccessResponses<T>: union of Standard Schema types for all JSON 2xx entries.InferSseSuccessResponses<T>: SSE event schema map type from aresponsesByStatusCodemap.HasAnySseSuccessResponse<T>,HasAnyJsonSuccessResponse<T>,HasAnyNonSseSuccessResponse<T>: boolean checks over 2xx entries.ContractResponseMode<T>:'dual'(SSE + non-SSE),'sse'(SSE-only), or'non-sse'.AvailableResponseModes<T>: union of'json' | 'sse' | 'blob' | 'text' | 'stream' | 'noContent'.SseEventOf<S>: discriminated union of SSE events inferred from aschemaByEventNamemap, aligned with the browserMessageEventshape:{ type, data, lastEventId, retry }.
Client types
Primarily consumed by HTTP client implementations.
ClientRequestParams<TApiContract, TIsStreaming>: infers the request parameter object (pathParams,body,queryParams,headers, optionalpathPrefix, andstreamingfor dual-mode contracts).InferSseClientResponse<TApiContract>: discriminated union of{ statusCode, headers, body }for SSE mode. Exact 2xx codes and'2xx'yieldAsyncIterable<SseEventOf<...>>.InferNonSseClientResponse<TApiContract>: same shape for non-SSE mode. Exact 2xx codes and'2xx'yield JSON /string/Blob/ReadableStream<Uint8Array>/null(SSE excluded).DefaultStreaming<T>:truefor SSE-only contracts,falseotherwise.
Contract type aliases
ApiContract: union of all contract variants (GetApiContract | DeleteApiContract | PayloadApiContract).GetApiContract,DeleteApiContract,PayloadApiContract: individual variants.RequestQuerySchema,RequestHeaderSchema,ResponseHeaderSchema: Standard Schema object-schema constraints for generic helpers.RequestPathParamsSchema: a Standard Schema that also implementsObjectKeysCarrier.ObjectKeysCarrier: the{ getObjectKeys(): readonly string[] }capability a path-param schema must add somapApiContractToPathcan read its keys (see Path mapping).
Utility functions
mapApiContractToPath(contract): Express/Fastify-style path pattern, e.g."/users/:userId".describeApiContract(contract): human-readable"METHOD /path"string.hasAnySuccessSseResponse(contract):truewhen any 2xx entry is an SSE response (including insideanyOfResponses).getSseSchemaByEventName(contract): extracts SSE event schemas, ornullwhen none are present.resolveResponseEntry(...)/resolveContractResponse(...): resolve a status code + content-type to a concreteResponseKind('json' | 'text' | 'blob' | 'stream' | 'sse' | 'noContent').
Validation
Vendor-neutral helpers for running a value through a Standard Schema, the equivalent of a schema
library's parse. They back request and response validation in
@toad-contracts/frontend-http-client and the mock helpers in
@toad-contracts/testing, so every package validates the same way.
validate(schema, value): async. Returns the parsed output (unknown keys stripped, transforms applied), awaiting schemas whose~standard.validateresolves asynchronously. ThrowsSchemaValidationErroron failure.validateSync(schema, value): synchronous variant. Throws aTypeErrorwhen the schema validates asynchronously, for callers that cannot await (e.g. buffered mock helpers).SchemaValidationError: thrown on validation failure. Carries the rawStandardSchemaV1.Issue[]in itsissuesproperty; accepts an optional custom message.
import { validate, validateSync, SchemaValidationError } from "@toad-contracts/core";
import { object, string } from "valibot";
const schema = object({ id: string() });
await validate(schema, { id: "1", extra: "dropped" }); // -> { id: "1" }
validateSync(schema, { id: "1" }); // -> { id: "1" }
try {
await validate(schema, { id: 42 });
} catch (error) {
if (error instanceof SchemaValidationError) {
console.error(error.issues);
}
}Module augmentation
To enforce stricter typing on metadata:
// file -> apiContracts.d.ts
import "@toad-contracts/core";
declare module "@toad-contracts/core" {
interface CommonRouteDefinitionMetadata {
myTestProp?: string[];
mySecondTestProp?: number;
}
}