@routepact/core
v0.2.5
Published
Type-safe route spec definitions shared between server and client
Downloads
1,437
Readme
@routepact/core
Shared types and utilities for defining type-safe route pacts. This package is the contract between your server and client — import it in shared code that both sides depend on.
Installation
npm install @routepact/coreYou also need a schema library that implements the Standard Schema interface (e.g. Zod, Valibot, ArkType). Examples below use Zod but any Standard Schema-compatible library works.
npm install zod # or valibot, arktype, etc.Defining a pact
definePact takes an object where each key is a route name and each value is a route definition with a path, method, and validation schemas. Every non-SSE route must declare a response map (keyed by HTTP status code); request and query are optional. SSE routes declare an events schema instead of response.
import { definePact } from "@routepact/core";
import { z } from "zod";
const post = z.object({ id: z.string(), title: z.string(), body: z.string() });
export const PostPacts = definePact({
list: {
method: "get",
path: "/posts",
response: {
200: z.object({
items: z.object({ id: z.string(), title: z.string() }).array(),
total: z.number(),
}),
},
query: z.object({
page: z.string().optional(),
limit: z.string().optional(),
}),
},
getById: {
method: "get",
path: "/posts/:id",
response: {
200: post,
404: z.object({ error: z.string() }),
},
},
create: {
method: "post",
path: "/posts",
request: z.object({ title: z.string(), body: z.string() }),
response: { 201: post },
},
update: {
method: "patch",
path: "/posts/:id",
request: z.object({ title: z.string().optional(), body: z.string().optional() }),
response: { 200: post },
},
delete: {
method: "delete",
path: "/posts/:id",
response: { 204: null }, // null = a status that carries no body
},
});Validation schemas
| Field | Required for | Description |
| ---------- | ------------------------------- | ------------------------------------------------------------------------------------------- |
| response | Every non-SSE route | Map of HTTP status code → object schema (or null for a bodyless status) |
| events | Every SSE route | Object schema for the SSE event shape — replaces response on sse: true routes |
| request | Only for post, patch, put | Object schema for the request body on the server |
| query | Optional | Object schema for query parameters — typed on both server and client |
A route is one or the other: a standard route declares response (and never events), an SSE route declares events (and never response). TypeScript rejects a route that declares neither, both, or the wrong one for its sse flag.
All schemas must describe object types (e.g. z.object(...) in Zod). Primitive or array schemas are not supported at the pact level.
Response statuses
response is keyed by HTTP status code, so each status gets its own validated body:
response: {
200: z.object({ id: z.string(), name: z.string() }),
404: z.object({ error: z.string() }),
204: null, // declared, but carries no body
}The server adapter validates the handler's body against the schema for the status it returned; the client validates the received body against the schema for the status it got back. A null entry declares a status that sends/receives no body — the handler returns just { status: 204 } and the adapters send an empty response.
The handler return type and the client result are both a discriminated union over the declared statuses, so you narrow on status to reach the matching body:
// handler
route.handler(({ params }) =>
found
? { status: 200, body: { id: params.id, name: "Alice" } }
: { status: 404, body: { error: "not found" } },
);Server-Sent Events (SSE)
Add sse: true to a route definition to mark it as a streaming endpoint. SSE routes declare an events schema (not response); the server adapter sets up the SSE response and injects a sendEvent function into the handler context. A discriminated union is the natural events schema, letting you send different event shapes in a single stream:
export const NotificationPacts = definePact({
stream: {
method: "get",
path: "/notifications/:userId",
sse: true,
events: z.discriminatedUnion("type", [
z.object({ type: z.literal("message"), text: z.string() }),
z.object({ type: z.literal("ping"), timestamp: z.number() }),
]),
},
});The handler return type becomes void for SSE routes — use sendEvent instead of returning { status, body }. sendEvent is typed to the events schema. See the adapter READMEs for the full handler example and connection lifetime details.
Path parameters
Parameters in the path (:param) are extracted as a type-safe object. The server adapter automatically populates params and types it as { [key: string]: string } based on the path string — no schema needed.
// TypeScript requires params when the path has parameters
await client.request(PostPacts.getById, { params: { id: "abc" } });
// TypeScript forbids params when there are none
await client.request(PostPacts.list);On the server, ctx.params is typed as { id: string } when the path is /posts/:id.
Query parameters
Add a query schema to make query parameters type-safe on both server and client. If any field in the schema is required, TypeScript will require the query option at the call site:
const list = {
method: "get",
path: "/posts",
query: z.object({ page: z.string().optional(), sort: z.string() }), // sort is required
};
// TypeScript requires query.sort
await request(PostPacts.list, { query: { sort: "createdAt" } });Validation errors
| Error class | Status | When |
| ------------------------- | ------ | --------------------------------------------- |
| RequestValidationError | 400 | Request body or query fails schema validation |
| ResponseValidationError | 500 | Response body fails schema validation |
Both extend ValidationError and expose a cause property with the Standard Schema issues array.
Type reference
| Type | Description |
| ------------------------------- | ---------------------------------------------------------------------------------------------------- |
| AnyPact | Record<string, AnyPactRoute> — a map of named route definitions |
| AnyPactRoute | Widened route type — HttpPactRoute<...> \| SsePactRoute<...> |
| PactRoute<TPath, TMethod> | A single route — the union of HttpPactRoute and SsePactRoute |
| HttpPactRoute<TPath, TMethod> | A standard route: response required (a ResponseSchemaMap), no events |
| SsePactRoute<TPath, TMethod> | An SSE route (sse: true): events required, no response |
| ResponseSchemaMap | { [status: number]: StandardSchemaObject \| null } — per-status response schemas (null = no body) |
| BaseRouteValidation | The shared optional schemas (request, query) every route may declare |
| HttpMethod | "get" \| "post" \| "patch" \| "put" \| "delete" |
| BuiltRouter | The result of calling .routes() on a RouterBuilder — passed to toExpressRouter / toHonoRouter |
| FinalizedRoute | The result of calling .handler() on a RouteBuilder |
| MiddlewareContext | Context object passed to .use() callbacks — includes params, query, body, extensions, plus framework properties |
| MiddlewareRequirementsContext | Context type used by defineMiddleware — narrows extensions and params based on declared requirements |
| HandlerContext | Context object passed to .handler() callbacks — same shape as MiddlewareContext, plus sendEvent (typed to the events schema) when sse: true |
| RouteHandlerResult<TPact> | Return type of a handler — a { status; body } discriminated union over the declared statuses (body optional for null statuses); void for SSE routes |
| RouteRequest<TPact> | Inferred output type of the request schema — never if the pact has no request schema |
| RouteParams<TPact> | Inferred path parameter record — {} if the path has no :param segments |
| RouteQuery<TPact> | Inferred output type of the query schema — {} if the pact has no query schema |
| RouteResponse<TPact> | Union of every status's inferred body type (undefined for null statuses) |
| RouteEvent<TPact> | Inferred output type of the events schema for SSE routes — never otherwise |
| RouteClientResult<TPact> | The client's return type — a { status; body } discriminated union over the declared statuses |
| ExtractParams<TPath> | Extracts parameter names from a path string |
| ExpectedParams<TPath> | Maps extracted param names to string values |
| RouteOptions<TRoute> | Inferred call-site options (params, payload, query) for the client |
| ValidationError | Base class for all validation errors — has status and cause: StandardSchemaV1.Issue[] |
| RequestValidationError | Extends ValidationError — thrown on bad request body or query (400) |
| ResponseValidationError | Extends ValidationError — thrown on bad response body (500) |
