@routepact/client
v0.5.0
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 }Path parameters
Each path parameter is percent-encoded into exactly one segment, so a value containing a separator is transmitted intact rather than changing the shape of the path — { id: "a/b" } against /posts/:id produces /posts/a%2Fb.
Three values cannot address a segment and are rejected with an error rather than sent: ".", "..", and "". The URL parser resolves them away before the request leaves, so { id: ".." } against /users/:id/profile would have requested /profile — a different endpoint, with your credentials attached. Encoding cannot avoid this: the URL parser reads %2e as ., so an escaped %2e%2e collapses just like ...
This matters when parameters come from user input — a route parameter read off the current page, a search field, a pasted identifier. If a value may legitimately be any of those three, it cannot be a path parameter; put it in the query string instead.
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, fetchImpl, defaultResponses, 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
},
});stream supplies its own signal — the one its cleanup function aborts — so a signal in fetchInit applies to request only. A timeout is the reason this matters: an SSE subscription is meant to outlive one.
fetchImpl
A stand-in for the global fetch, used by both request and stream. It receives the fully built Request — the one the beforeRequest hooks have already seen — and returns the Response.
This exists for React Native: its built-in fetch buffers the whole response body, so a stream opened with it delivers nothing until the endpoint closes. expo/fetch streams:
import { fetch as expoFetch } from "expo/fetch";
import { createClient, type FetchImpl } from "@routepact/client";
export const client = createClient("https://api.example.com", {
fetchImpl: expoFetch as FetchImpl,
});The default is the global fetch, which streams in browsers and on Node.
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 the connection with fetch, parses the event stream, validates each event against the pact's events schema, and returns a function that closes the connection.
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) => {
// see "Stream failures" below
console.error(error);
},
);
// later — aborts the request and stops reading
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),
);Because stream goes through fetch, it is the same request path as request: fetchInit, fetchImpl, and the beforeRequest / afterResponse hooks all apply, so an SSE route behind auth is reachable with the headers or cookies your hooks already set. It runs anywhere fetch streams — including React Native, where EventSource does not exist (see fetchImpl).
get and post
An SSE pact route may be get or post; the other three methods are a type error. get is the subscription form — parameters and query, no body — and stays consumable from a bare EventSource in a browser. Use post when the subscription needs a payload that doesn't fit in a query string:
const close = client.stream(
AssistantPacts.ask, // { path: "/ask", method: "post", sse: true, request, events }
{ payload: { prompt: "why is the sky blue?" } },
(event) => console.log(event),
);The payload is validated against the pact's request schema before anything is sent, exactly as in request.
Stream failures
Everything that goes wrong reaches onError; nothing is swallowed.
| what happened | what onError receives |
| --- | --- |
| the server answered non-2xx before the stream began | StreamStatusError with .status and the body validated against defaultResponses — an expired session arrives as a typed 401 |
| that status is declared by neither the pact nor defaultResponses | UnexpectedStatusError, as in request |
| the response was 2xx but not text/event-stream | StreamContentTypeError with .contentType — usually a proxy or a login page in front of the endpoint |
| the payload or an event failed its schema | ClientValidationError |
| an event was not valid JSON | the SyntaxError from parsing it |
| the connection was cut mid-stream | the transport's error — a truncated chunked response never reaches its terminating chunk, so the reader rejects rather than reporting a clean end |
A malformed or invalid single event does not end the subscription — it is reported and reading continues. Everything else ends it. A stream that ends normally calls neither callback again, and closing it yourself is not an error: after close(), onError is not called.
No automatic reconnect.
EventSourcereconnects on its own after a dropped connection;fetchdoes not, andstreamdeliberately does not rebuild it. A silent reconnect replays the stream without the caller knowing — on an expensive or non-idempotent endpoint a visible error is better than an invisible retry. Reconnect inonErrorif you want it, where you can decide whether replaying is safe.
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 afetchconnection for ansse: truepact (getorpost), validates each event against itseventsschema, and returns a cleanup() => voidthat aborts it. Failures go toonError. 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;
/** Stand-in for the global fetch, used by both request and stream — e.g. `fetch` from `expo/fetch`. */
fetchImpl?: (request: Request) => Promise<Response>;
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 fetch subscription for an SSE pact route — returns a cleanup () => void |
| RequestConfig | Config for createClient — fetchInit, fetchImpl, defaultResponses, beforeRequest, and afterResponse |
| FetchImpl | (request: Request) => Promise<Response> — the shape of fetchImpl |
| 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 |
| StreamStatusError | Passed to a stream's onError when the server answered non-2xx before the stream began — has status and validated body |
| StreamContentTypeError | Passed to a stream's onError when the response was not text/event-stream — has contentType |
