@routepact/express
v0.5.1
Published
Express adapter for type-safe route pacts - endpoint builders, request/response validation middleware, and router setup
Maintainers
Readme
@routepact/express
Express adapter for route pacts, with validation and typed or native middleware.
Installation
npm install @routepact/express @routepact/core express
npm install -D @types/expressAdd a Standard Schema library. Examples use Zod.
npm install zodCore concepts
createRouter(pact)
Build handlers and middleware for a pact, then pass the result to toExpressRouter.
import { createRouter, toExpressRouter } from "@routepact/express";
import { definePact } from "@routepact/core";
import express from "express";
import { z } from "zod";
const PostPacts = definePact({
getById: {
method: "get",
path: "/posts/:id",
response: { 200: z.object({ id: z.string(), title: z.string() }) },
},
create: {
method: "post",
path: "/posts",
request: z.object({ title: z.string() }),
response: { 201: z.object({ id: z.string(), title: z.string() }) },
},
});
const built = createRouter(PostPacts).routes({
getById: (route) =>
route.handler(({ params }) => ({
status: 200,
body: { id: params.id, title: "Hello" },
})),
create: (route) =>
route.handler(({ body }) => ({
status: 201,
body: { id: "1", title: body.title },
})),
});
const app = express();
app.use(express.json());
app.use(toExpressRouter(built));
app.listen(3000);toExpressRouter(built, options?)
Converts a BuiltRouter into an Express Router that can be mounted with app.use(). Setup checks for missing handlers, duplicate routes, and malformed pacts. It reports all problems before serving requests.
onStreamError is required for SSE routes. See Validation.
Handler return value
Return { status, body } using a declared status. TypeScript checks the body type; the adapter validates it. For a null status, return only { status }.
// response: { 200: Post, 404: z.object({ error: z.string() }), 204: null }
route.handler(({ params }) =>
exists(params.id)
? { status: 200, body: { id: params.id, title: "Hello" } }
: { status: 404, body: { error: "not found" } },
)
route.handler(() => ({ status: 204 })) // 204 declared as null — no bodyServer-Sent Events (SSE)
SSE routes use sse: true and an events schema. Call sendEvent to send validated events. A discriminated union supports multiple event shapes:
const EventPacts = definePact({
stream: {
method: "get",
path: "/events/:roomId",
sse: true,
events: z.discriminatedUnion("type", [
z.object({ type: z.literal("message"), text: z.string() }),
z.object({ type: z.literal("ping"), timestamp: z.number() }),
]),
},
});
const built = createRouter(EventPacts).routes({
stream: (route) =>
route.handler(async ({ params, sendEvent }) => {
await sendEvent({ type: "message", text: `Hello from ${params.roomId}` });
await sendEvent({ type: "ping", timestamp: Date.now() });
// handler returns void — stream closes when the function resolves
}),
});
const app = express();
app.use(express.json());
app.use(
toExpressRouter(built, {
// Required for SSE routes — see "SSE handler failures" below.
onStreamError: (error) => logger.error({ error }, "SSE stream failed"),
}),
);
app.listen(3000);sendEventvalidates and writes adata: ...\n\nframe.- SSE routes support
getorpost;postcan validate a request body. Content-Type: text/event-stream,Cache-Control: no-cache, andConnection: keep-aliveare set automatically- The connection closes when the
voidhandler returns. Keep it running for a long-lived stream. Usereq.on("close", ...)to detect client disconnect:
route.handler(async ({ sendEvent, req }) => {
let closed = false;
req.on("close", () => { closed = true; });
while (!closed) {
await sendEvent({ type: "ping", timestamp: Date.now() });
await new Promise(r => setTimeout(r, 30_000));
}
})Handler context
| Property | Type | Description |
| ------------ | ---------------------------- | ---------------------------------------------------------------------------------- |
| params | inferred from path string | Path parameters as matched by the router, percent-decoded (e.g. { id: string } for /posts/:id). {} if no params. |
| query | inferred from query schema | Validated query parameters. {} if the pact has no query schema — undeclared params are not passed through. |
| body | inferred from request schema | Parsed and validated request body. undefined if the pact has no request schema. |
| extensions | merged middleware returns | Typed object with all additions returned by upstream middleware. {} if none. |
| req | RoutePactRequest | Raw Express request — use for headers, cookies, IP, etc. |
| res | Response | Express response object. |
| next | NextFunction | Express next function — use to forward errors to your error handler. |
Middleware
Middleware and validation order
Middleware runs in this order:
router middleware → validation → route middleware → handlerRouter middleware cannot access validated body or query data. Route middleware can.
Put authentication in router middleware to reject requests before validation:
createRouter(PostPacts)
.use(authenticate) // runs first; rejects before validation
.routes({ ... });Route middleware can use validated input. Router middleware also runs for requests that fail validation, which suits logging and rate limits.
Router-level middleware (.use())
Runs for every route. Return an object to add typed extensions downstream.
const built = createRouter(PostPacts)
.use(({ req }) => ({ userId: req.headers["x-user-id"] as string }))
.routes({
getById: (route) =>
route.handler(({ extensions }) => ({
status: 200,
body: { id: "1", title: `Viewed by ${extensions.userId}` },
})),
// ...
});Return nothing if the middleware adds no extensions.
Route-level middleware (.use())
Runs on one route, with access to its validated params, query, and body.
route
.use(({ params, body }) => ({ capturedId: params.id }))
.handler(({ extensions }) => ({
status: 200,
body: { id: extensions.capturedId, title: "Hello" },
}))defineMiddleware
Define reusable middleware with typed Express context:
import { defineMiddleware } from "@routepact/express";
// No requirements — everything inferred from the function body
const withAuth = defineMiddleware(({ req }) => {
const userId = req.headers["x-user-id"] as string;
if (!userId) throw new Error("Unauthorized");
return { userId };
});
// Use at router or route level
createRouter(PostPacts)
.use(withAuth)
.routes({ ... });
// Or on a specific route
route.use(withAuth).handler(({ extensions }) => {
// extensions.userId is typed as string
});Declaring requirements
Declare required inputs and added extensions with type parameters:
defineMiddleware<TAdds, TRequirements>(fn)| Parameter | Default | Description |
| --- | --- | --- |
| TAdds | void | Object added to extensions, or void for guards that add nothing |
| TRequirements | {} | Config object with optional extensions (shape extensions must have), params (path params that must be present), query (shape the query must have), and/or body (shape the request body must have) |
Guard that requires a prior extension and a specific path param:
type User = { id: string; role: string };
// Requires extensions.user (set by withAuth) and params.spaceId (from /:spaceId route)
const spaceGuard = defineMiddleware<void, { extensions: { user: User }; params: { spaceId: string } }>(
({ extensions, params }) => {
if (!canAccess(extensions.user, params.spaceId)) {
throw new Error("Forbidden");
}
}
);
createRouter(SpacePacts).routes({
getSpace: (route) =>
route
.use(withAuth) // adds extensions.user
.use(spaceGuard) // TypeScript enforces: user in extensions ✓, :spaceId in path ✓
.handler(...)
});Middleware that adds to extensions and requires a prior extension:
// Adds extensions.role, but requires extensions.userId to already be present
const withRole = defineMiddleware<{ role: string }, { extensions: { userId: string } }>(
({ extensions }) => ({ role: getRole(extensions.userId) })
);Middleware that reads the request body:
// Reads body.orgId — only usable on routes whose request schema includes orgId
const withOrg = defineMiddleware<{ org: Org }, { body: { orgId: string } }>(
({ body }) => ({ org: getOrg(body.orgId) })
);
createRouter(OrgPacts).routes({
create: (route) =>
route
.use(withOrg) // TypeScript enforces: request schema includes orgId ✓
.handler(({ extensions }) => ({
status: 201,
body: { id: extensions.org.id },
})),
});TypeScript rejects middleware whose requirements are not met.
Native Express middleware (.useNative())
Use Express RequestHandler functions alongside typed middleware.
import compression from "compression";
// Router level
createRouter(PostPacts)
.useNative(compression())
.routes({ ... });
// Route level
route
.useNative((req, res, next) => {
req.headers["x-injected"] = "value";
next();
})
.handler(({ req }) => ({
status: 200,
body: { id: req.headers["x-injected"] as string, title: "Hello" },
}))Controller pattern
Use ExpressHandlerContext to type controller methods outside the router builder:
import { createRouter, toExpressRouter, ExpressHandlerContext } from "@routepact/express";
type AuthExtensions = { userId: string };
class PostController {
getById(ctx: ExpressHandlerContext<typeof PostPacts["getById"], AuthExtensions>) {
return { status: 200, body: { id: ctx.params.id, title: "Hello" } };
}
}
const controller = new PostController();
const built = createRouter(PostPacts)
.use(({ req }): AuthExtensions => ({ userId: req.headers["x-user-id"] as string }))
.routes({
getById: (route) => route.handler(controller.getById.bind(controller)),
// ...
});Note: When passing a class method as a handler,
thismust be bound explicitly — otherwise it will beundefinedat call time. Use.bind(controller)or wrap it in an arrow function:// bind route.handler(controller.getById.bind(controller)) // arrow wrapper route.handler((ctx) => controller.getById(ctx))
Omit TExtensions when there are no extensions.
Validation
Request validation — if the pact has a request schema (for POST/PATCH/PUT) or a query schema, the incoming data is validated before the handler runs. On failure, next(error) is called with a RequestValidationError (status 400).
JSON request bodies — a route that declares a request schema only accepts application/json (including charset parameters and +json suffixes such as application/vnd.api+json). Anything else is rejected with an UnsupportedMediaTypeError (status 415) before the body is read.
JSON content type triggers a CORS preflight for cross-origin writes. For cookie-authenticated APIs, also protect bodyless GET and DELETE routes with SameSite cookies or a CSRF token.
Array query params — z.array(z.string()) receives ["a"] for ?tags=a and ["a", "b"] for ?tags=a&tags=b.
Response validation — the handler's body is validated against the schema for the status it returned. A status mapped to null (or returned with no body) skips validation. On failure, next(error) is called with a ResponseValidationError (status 500).
SSE handler failures — once the first event is written the headers are on the wire, so a later failure cannot become an error status. Routepact closes the stream and passes the error to onStreamError instead of next(); handing it to your error handler would only produce an ERR_HTTP_HEADERS_SENT that hides the original. This is the only place these errors surface, so wire it to your logger:
app.use(
toExpressRouter(built, {
onStreamError: (error) => logger.error({ error }, "SSE stream failed"),
}),
);onStreamError is required for SSE routers. Use () => {} only if you intend to discard errors. Clients receive a cut stream after any events already sent.
Register an error handler in your Express app to format validation errors:
import { RequestValidationError, ResponseValidationError, ValidationError } from "@routepact/express";
app.use((error, req, res, next) => {
// Express requires this: once a response has started, an error handler cannot
// set a status, and trying throws ERR_HTTP_HEADERS_SENT over the original.
if (res.headersSent) {
return next(error);
}
if (error instanceof RequestValidationError) {
return res.status(400).json({ message: "Invalid request" });
}
if (error instanceof ResponseValidationError) {
console.error("Response validation failed:", error.cause);
return res.status(500).json({ message: "Internal server error" });
}
// Or catch any validation error — `status` carries the right code, including
// 415 for a body that was not sent as JSON.
if (error instanceof ValidationError) {
return res.status(error.status).json({ message: error.message });
}
next(error);
});ValidationError.cause holds the underlying Standard Schema issues.
Duplicate route detection
toExpressRouter rejects duplicate method-and-path pairs at startup. Different methods may share a path.
