@routepact/client
v0.2.5
Published
Type-safe HTTP client for route pacts - validates responses against schemas
Readme
@routepact/client
Type-safe HTTP client for @routepact/core pacts built on native fetch. Pass a pact and get back a fully-typed response — params, payload, queries, and return type are all inferred automatically.
Installation
npm install @routepact/client @routepact/coreYou also need a schema library that implements the Standard Schema interface (e.g. Zod, Valibot, ArkType) for defining your pacts. Examples below use Zod but any Standard Schema-compatible library works.
npm install zod # or valibot, arktype, etc.Setup
Create a client bound to a base URL, then call its request method. You typically do this once and export it for use across your app.
import { createClient } from "@routepact/client";
export const client = createClient("https://api.example.com");Making requests
Pass a pact to client.request. TypeScript infers everything from the pact - what options are required, whether params, payload, or query are needed, and the return type.
The result is always { status, body } — a discriminated union over the statuses declared in the pact's response map. Narrow on status to reach the matching, validated body:
import { PostPacts } from "../shared/pacts/post.pact";
// GET /posts/:id — response: { 200: Post, 404: { error: string } }
const res = await client.request(PostPacts.getById, { params: { id: "abc" } });
// res: { status: 200; body: Post } | { status: 404; body: { error: string } }
if (res.status === 200) {
console.log(res.body.title); // body is Post here
} else {
console.error(res.body.error); // body is { error: string } here
}
// GET /posts — when only one status is declared, body is directly accessible
const list = await client.request(PostPacts.list);
// list: { status: 200; body: { items: ...; total: number } }
list.body.items;
// POST /posts - payload is required, typed from the request schema
const created = await client.request(PostPacts.create, {
payload: { title: "Hello", body: "World" },
});
// created: { status: 201; body: Post }
// PATCH /posts/:id - both params and payload
const updated = await client.request(PostPacts.update, {
params: { id: "abc" },
payload: { title: "Updated title" },
});
// DELETE /posts/:id — response: { 204: null }
const removed = await client.request(PostPacts.delete, { params: { id: "abc" } });
// removed: { status: 204; body: undefined }Query parameters
Query parameters are typed from the pact's query schema. If the schema has required fields, TypeScript will require query at the call site:
// pact defined with: query: z.object({ page: z.string().optional(), sort: z.string() })
const posts = await client.request(PostPacts.list, {
query: { sort: "createdAt", page: "2" },
});
// -> GET /posts?sort=createdAt&page=2If the pact has no query schema, query accepts never and TypeScript will prevent you from passing it.
Config
Pass a RequestConfig object as the second argument to createClient. It accepts fetchInit, beforeRequest hooks, and afterResponse hooks — all optional and composable.
fetchInit
Any standard RequestInit field (except method, body, and headers which are managed internally) is forwarded to every fetch call. This is the right place for credentials, mode, cache, signal, etc.
import { createClient } from "@routepact/client";
export const client = createClient("https://api.example.com", {
fetchInit: {
credentials: "include", // send cookies on every request
signal: AbortSignal.timeout(30_000), // 30 s timeout
},
});defaultResponses
A status-keyed map of fallback response schemas applied to every request made through the client. When the server returns a status that the pact's own response map doesn't declare, the client falls back to the schema for that status in defaultResponses — validating and returning it as a normal { status, body } result instead of throwing UnexpectedStatusError.
This is the place for statuses your whole API shares — a uniform error envelope, a 401, a 429 — so you don't have to repeat them in every pact:
import { z } from "zod";
export const client = createClient("https://api.example.com", {
defaultResponses: {
400: z.object({ error: z.string() }),
500: z.object({ error: z.string() }),
},
});
// For a pact declaring only { 200: User }, the result type now includes
// the default statuses too:
const res = await client.request(UserPacts.getById, { params: { id: "1" } });
// res: { status: 200; body: User }
// | { status: 400; body: { error: string } }
// | { status: 500; body: { error: string } }
if (res.status === 500) {
console.error(res.body.error); // validated against the default 500 schema
}A pact's own response map always takes precedence: if a route declares its own schema for a status, that one is used and the default is ignored. Statuses covered by neither the pact nor defaultResponses still throw UnexpectedStatusError. Use null for a default status that carries no body (e.g. { 204: null }).
Hooks
beforeRequest and afterResponse hook arrays run logic around each fetch. This is the place to add auth headers, log errors, or handle token refresh.
export const client = createClient("https://api.example.com", {
fetchInit: { credentials: "include" },
beforeRequest: [
(req) =>
new Request(req, {
headers: {
...Object.fromEntries(req.headers),
Authorization: `Bearer ${getToken()}`,
},
}),
],
afterResponse: [
(_req, res) => {
if (res.status === 401) {
logout();
}
},
],
});Both hook types support async functions. A beforeRequest hook can return a new Request object to replace the outgoing request entirely, and an afterResponse hook can return a new Response to replace what the client sees.
Response validation and statuses
The client looks up the schema for the actual HTTP status it received and validates the body against it. This means declared error statuses (e.g. a 404) come back as a normal, typed result — the client does not throw on non-2xx responses:
const res = await client.request(PostPacts.getById, { params: { id: "abc" } });
if (res.status === 404) {
// res.body is the validated 404 schema — handled, not thrown
}Two things are thrown instead:
ClientValidationError— the body for a declared status fails its schema.UnexpectedStatusError— the server returned a status that neither the pact'sresponsemap nor the client'sdefaultResponsesdeclares. The contract can't describe its body, so it's surfaced as an error (carrying.statusand the parsed JSON.body, or{}if the response wasn't valid JSON) rather than a mistyped value. This is what keeps the{ status, body }return type sound: every value the client returns has a declared status whose body matches.
import { ClientValidationError, UnexpectedStatusError } from "@routepact/client";
try {
const res = await client.request(PostPacts.getById, { params: { id: "abc" } });
// ... narrow on res.status ...
} catch (error) {
if (error instanceof ClientValidationError) {
console.error(error.cause); // Standard Schema issues array
} else if (error instanceof UnexpectedStatusError) {
console.error(error.status, error.body); // undeclared status + raw body
}
}If you want to handle a status, declare it in the pact's
responsemap (usenullfor a bodyless status like204), or in the client'sdefaultResponsesif it's common across your API. Anything you leave out of both becomes anUnexpectedStatusError. Note thatafterResponsehooks still run for every response, including undeclared statuses, before this check — so you can observe e.g. a401in a hook even though the request itself will reject.
Multiple API instances
You can create multiple clients pointing to different APIs:
export const internalApi = createClient("https://internal.example.com", {
beforeRequest: [(req) => req.headers.set("X-Internal", "1")],
});
export const externalApi = createClient("https://api.partner.com");Server-Sent Events (SSE)
For SSE routes (marked with sse: true in the pact), use the client's stream method. It opens an EventSource connection, validates each incoming event against the pact's events schema, and returns a cleanup function that closes the stream.
import { createClient } from "@routepact/client";
const client = createClient("https://api.example.com");
const close = client.stream(
EventPacts.stream,
{ params: { roomId: "general" } },
(event) => {
// event is fully typed from the pact's events schema
if (event.type === "message") console.log(event.text);
if (event.type === "ping") console.log("ping", event.timestamp);
},
(error) => {
// called on connection errors (Event) or validation failures (ClientValidationError)
console.error(error);
},
);
// later — closes the EventSource
close();When the pact has no required params or query, pass undefined as the options argument:
const close = client.stream(
NotificationPacts.stream,
undefined,
(event) => console.log(event),
);stream uses the browser's native EventSource API under the hood. It is intended for browser environments — for Node.js SSE clients, use fetch with a readable stream instead. Note that EventSource cannot send custom headers or a request body, so the client's fetchInit and request hooks do not apply to SSE subscriptions.
API reference
createClient(baseUrl, config?)
Returns a client object with request and stream methods. The client is generic over the config's defaultResponses, which widen every result's status union:
{
request: <TPact extends AnyPactRoute>(
pact: TPact,
// options is required only when the pact needs params, payload, or query
...[options]: RouteOptionsRequired<TPact> extends true
? [options: RouteOptions<TPact>]
: [options?: RouteOptions<TPact>]
) => Promise<RouteClientResult<TPact, TDefaultResponse>>;
stream: <TPact extends AnyPactRoute & { sse: true }>(
pact: TPact,
options: RouteOptions<TPact> | undefined,
onEvent: (event: RouteEvent<TPact>) => void,
onError?: (error: unknown) => void,
) => () => void;
}pact- a pact created withdefinePactfrom@routepact/coreoptions.params- required when the path contains:paramsegmentsoptions.payload- required forpost,patch,putwhen the pact has arequestschemaoptions.query- typed from the pact'squeryschema; required if the schema has required fieldsrequestreturnsRouteClientResult<TPact, TDefaultResponse>- a{ status, body }discriminated union over the pact's declared response statuses plus any statuses added viadefaultResponses. ThrowsClientValidationErrorif the body fails its status schema, orUnexpectedStatusErrorif the status was declared by neither.streamopens anEventSourcefor ansse: truepact, validates each event against itseventsschema, and returns a cleanup() => void. See Server-Sent Events.
RequestConfig
interface RequestConfig<TDefaultResponse extends ResponseSchemaMap> {
/** Forwarded verbatim to every fetch call — use for credentials, mode, cache, signal, etc. */
fetchInit?: Omit<RequestInit, "method" | "body" | "headers">;
/** Fallback response schemas (status -> schema), used when the pact doesn't declare the returned status. */
defaultResponses?: TDefaultResponse;
beforeRequest?: Array<(request: Request) => Request | void | Promise<Request | void>>;
afterResponse?: Array<(request: Request, response: Response) => Response | void | Promise<Response | void>>;
}Type reference
| Export | Description |
| ---------------------------------- | ---------------------------------------------------------------------------------------------------- |
| createClient(baseUrl, config?) | Creates a typed client bound to a base URL with optional config — exposes request and stream methods |
| client.stream(...) | Opens a typed EventSource subscription for an SSE pact route — returns a cleanup () => void |
| RequestConfig | Config for createClient — fetchInit, defaultResponses, beforeRequest, and afterResponse |
| ClientValidationError | Thrown when response or payload validation fails — has field and cause: StandardSchemaV1.Issue[] |
| UnexpectedStatusError | Thrown when the response status isn't declared in the pact's response map — has status and parsed body |
