@routepact/express
v0.1.13
Published
Express adapter for type-safe route pacts - endpoint builders, request/response validation middleware, and router setup
Maintainers
Readme
@routepact/express
Express adapter for @routepact/core pacts. Provides type-safe endpoint builders, request/response validation middleware, and a versioned router factory.
Installation
npm install @routepact/express @routepact/core express
npm install -D @types/expressYou 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.Core concepts
defineEndpoint
Wraps a handler with a pact. Instead of receiving the raw Express req/res, the handler receives a typed context object whose types are inferred directly from the pact's schemas.
import { defineEndpoint } from "@routepact/express";
import { PostPacts } from "../shared/pacts/post.pact";
const createPost = defineEndpoint({
pact: PostPacts.create,
handler: async ({ body, res }) => {
// body is typed as { title: string; body: string } (from pact.request)
const post = await db.posts.insert(body);
// res.json only accepts the shape defined by PostPacts.create.response
res.status(201).json(post);
},
});Returning a value is a shorthand for res.json(value) - the framework calls it for you:
const createPost = defineEndpoint({
pact: PostPacts.create,
handler: async ({ body }) => {
return await db.posts.insert(body); // sent as res.json(post) automatically
},
});Context properties
| Property | Type | Description |
| ------------- | ------------------------- | ------------------------------------------------------------------------------- |
| body | inferred from request | Parsed and validated request body. never if the pact has no request schema. |
| params | inferred from path string | Path parameters (e.g. { id: string } for /posts/:id). never if no params. |
| query | inferred from query | Validated query parameters. never if the pact has no query schema. |
| extensions | merged middleware returns | Typed object containing all additions returned by endpoint middlewares. {} if none. |
| req | Request | Raw Express request - use for headers, cookies, IP, etc. |
| res | TypedServerResponse<TBody> | Express response with json() typed to the pact's response schema. |
| next | NextFunction | Express next function - use to forward errors to your error handler. |
const getPostById = defineEndpoint({
pact: PostPacts.getById, // path: "/posts/:id"
handler: ({ params }) => {
// params is typed as { id: string }
return db.posts.findById(params.id);
},
});
const listPosts = defineEndpoint({
pact: PostPacts.list, // has query schema with optional page field
handler: ({ query }) => {
// query is typed as { page?: string }
return db.posts.list({ page: query.page });
},
});For anything beyond body/params/query - headers, cookies, IP address, etc. - use ctx.req:
const createPost = defineEndpoint({
pact: PostPacts.create,
handler: ({ body, req }) => {
const ip = req.ip;
// ...
},
});createRouter
Takes a config with a basePath and route groups and returns an Express router mounted at that path.
import { createRouter } from "@routepact/express";
import {
createPost,
getPostById,
listPosts,
updatePost,
deletePost,
} from "./post.endpoints";
export const postRouter = createRouter({
basePath: "/api/v1",
routeGroups: [
{
description: "Posts",
endpoints: [listPosts, getPostById, createPost, updatePost, deletePost],
},
],
});
// Mount in your Express app
app.use(postRouter);
// Routes are now available at /api/v1/posts, /api/v1/posts/:id, etc.Options:
createRouter(config, {
logger: myLogger, // default: console - must have an `error` method
});The router detects and throws on duplicate route registrations ([method] path pairs) at startup rather than silently shadowing them.
defineRouteGroup
Helper to define a route group with full type checking before passing it to createRouter:
import { defineRouteGroup } from "@routepact/express";
const publicRoutes = defineRouteGroup({
description: "Public routes",
endpoints: [listPosts, getPostById],
});Middleware
Group middleware
Apply Express middleware to every endpoint in a route group. Common use case: protecting a group of routes with an auth check.
import { createRouter } from "@routepact/express";
import { requireAuth } from "./middlewares/auth";
import { listPosts, createPost } from "./post.endpoints";
const router = createRouter({
basePath: "/api/v1",
routeGroups: [
{
description: "Public routes",
endpoints: [listPosts],
},
{
description: "Protected routes",
middlewares: [requireAuth],
endpoints: [createPost],
},
],
});Endpoint middleware (createEndpointMiddleware)
Type-safe middleware that adds properties directly to the handler context. The properties returned by the middleware are merged into the context type seen by the handler.
import { createEndpointMiddleware, defineEndpoint } from "@routepact/express";
import { PostPacts } from "../shared/pacts/post.pact";
import { getSessionFromRequest } from "./auth";
// Declare what this middleware adds to the context
const withUser = createEndpointMiddleware(async (req) => {
const session = await getSessionFromRequest(req);
if (!session) throw new Error("Unauthorized");
return { user: session.user }; // TypeScript infers { user: User }
});
const createPost = defineEndpoint({
pact: PostPacts.create,
middlewares: [withUser],
handler: async ({ body, extensions }) => {
// extensions.user is typed as User - no casting needed
return db.posts.insert({ ...body, authorId: extensions.user.id });
},
});Multiple middlewares are supported and their added types are merged:
const getPost = defineEndpoint({
pact: PostPacts.getById,
middlewares: [withUser, withRateLimit],
handler: async ({ params, extensions }) => {
// extensions has both .user (from withUser) and .rateLimit (from withRateLimit)
},
});Validation middleware
Request and response validation is applied automatically when a pact has the corresponding schemas. You don't need to call these manually unless you want to use them outside of createRouter.
import {
createRequestValidationMiddleware,
createResponseValidationMiddleware,
} from "@routepact/express";
import { z } from "zod";
// Validates req.body against the schema, result available as ctx.body
app.use(createRequestValidationMiddleware(z.object({ name: z.string() })));
// Validates res.json(...) before sending
app.use(
createResponseValidationMiddleware(
z.object({ id: z.string(), name: z.string() }),
),
);Validation errors
When validation fails, the middleware calls next(error) with one of these:
| Error class | Status | When |
| ------------------------- | ------ | ------------------------------------------ |
| RequestValidationError | 400 | req.body or req.query fails validation |
| ResponseValidationError | 500 | res.json(...) fails the response schema |
Both expose a cause property with the Standard Schema issues array. Register an error handler in your Express app to format them:
import {
ValidationError,
RequestValidationError,
ResponseValidationError,
} from "@routepact/express";
app.use((err, req, res, next) => {
if (err instanceof RequestValidationError) {
return res
.status(400)
.json({ message: "Invalid request", errors: err.cause });
}
if (err instanceof ResponseValidationError) {
console.error("Response validation failed:", err.cause);
return res.status(500).json({ message: "Internal server error" });
}
// Or catch any validation error regardless of type:
if (err instanceof ValidationError) {
return res.status(err.status).json({ message: err.message });
}
next(err);
});Type reference
| Export | Description |
| --------------------------------------------- | -------------------------------------------------------------------------- |
| defineEndpoint(config) | Creates a typed endpoint from a pact + handler |
| defineRouteGroup(group) | Creates a typed route group |
| createEndpointMiddleware(handler) | Creates a type-augmenting middleware |
| createRouter(config, options?) | Builds a versioned Express router |
| createRequestValidationMiddleware(schema) | Validates req.body and stores result in handler context as body |
| createResponseValidationMiddleware(schema?) | Validates res.json(...) output before sending |
| 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) |
| PactHandlerContext<TPact, TMiddlewares> | The typed context object passed to every handler - middleware additions are under .extensions |
| TypedServerResponse<TBody> | Express Response with json() narrowed to TBody |
| PactServerRequest | Express Request extended with the _routepact namespace |
| PactServerRequestHandler | Request handler using PactServerRequest and PactServerResponse |
| PactServerResponse<TPact> | TypedResponse specialised for a pact's response schema |
| EndpointMiddleware<TAdds> | A middleware that augments the handler context with TAdds |
| RouteEndpoint<TPact, TMiddlewares> | Full endpoint type including handler and middlewares |
| RouteGroup | A group of endpoints with optional shared middleware |
| RouterConfig | Config passed to createRouter |
| RouterOptions | Options passed to createRouter - logger |
| Logger | Pick<Console, "error"> - the logger interface expected by createRouter |
