Composable Middleware Pipelines in TypeScript
Stop bolting middleware onto frameworks and start building type-safe, composable pipeline primitives you can test in isolation and reason about at a glance.

Middleware is one of those patterns every backend developer uses constantly and almost nobody designs deliberately. You inherit an Express app with forty app.use() calls, or a Next.js project where every route reimplements auth and logging differently, and you accept that as the natural state of things. It doesn't have to be.
Building middleware as composable, typed primitives — rather than framework callbacks bolted together — makes pipelines testable in isolation, reorderable without fear, and readable as data flow rather than side effects.
The Problem with Framework-Native Middleware
Express middleware has a signature that's been copied everywhere: (req, res, next) => void. It's flexible, but that flexibility is the problem. Nothing in the type system tells you what a middleware expects to find on req, what it adds to it, or whether it will call next at all.
// ❌ Implicit contract — caller has no idea what this requires or produces
app.use(requireAuth);
app.use(loadTenant);
app.use(enforceRateLimits);
router.get("/invoices", async (req, res) => {
// Is req.user here? req.tenant? Who added them?
const invoices = await getInvoices(req.user.id, req.tenant.id);
res.json(invoices);
});The middleware ran — probably — but there's no compile-time guarantee. If you reorder those three lines or skip one, you get a runtime crash and a confused on-call engineer at 2am.
Defining a Typed Context Pipeline
The fix is to make the context type explicit at every step. A middleware function transforms a context object from one shape to another, or short-circuits with an error.
type Next<TIn, TOut> = (ctx: TIn) => Promise<TOut>;
type Middleware<TIn, TOut, TNext = TOut> = (
ctx: TIn,
next: Next<TIn, TNext>,
) => Promise<TOut>;
// A pipeline stage that enriches the context
type Enrich<TIn, TExtra> = Middleware<TIn, TIn & TExtra>;This signature means the compiler enforces that each stage receives exactly what it needs. If enforceRateLimits needs a user property on context, it simply types its input accordingly — and the build fails if you place it before requireAuth.
Building the Compose Function
Composing a list of middleware into a single handler is straightforward once the types are right.
function compose<TCtx>(
...middlewares: Array<(ctx: TCtx, next: () => Promise<TCtx>) => Promise<TCtx>>
): (ctx: TCtx) => Promise<TCtx> {
return function dispatch(ctx: TCtx): Promise<TCtx> {
let index = -1;
function step(i: number, currentCtx: TCtx): Promise<TCtx> {
if (i <= index) {
return Promise.reject(new Error("next() called multiple times"));
}
index = i;
const middleware = middlewares[i];
if (!middleware) return Promise.resolve(currentCtx);
return middleware(currentCtx, (nextCtx) => step(i + 1, nextCtx ?? currentCtx));
}
return step(0, ctx);
};
}This is the same onion model Koa uses internally, but extracted and typed independently of any framework. You can use it in a Next.js route, a Lambda handler, a background job processor, or a pure unit test.
Concrete Middleware Implementations
Here's what real middleware looks like in this model — each stage declares what it needs and what it adds.
type BaseCtx = { requestId: string; startedAt: number };
type AuthCtx = BaseCtx & { user: { id: string; roles: string[] } };
type TenantCtx = AuthCtx & { tenant: { id: string; plan: "free" | "pro" } };
// Auth middleware: BaseCtx → AuthCtx
const withAuth: Enrich<BaseCtx, { user: AuthCtx["user"] }> = async (ctx, next) => {
const token = getTokenFromRequest(ctx.requestId);
const user = await verifyToken(token);
if (!user) {
throw new HttpError(401, "Unauthorized");
}
return next({ ...ctx, user });
};
// Tenant middleware: AuthCtx → TenantCtx
const withTenant: Enrich<AuthCtx, { tenant: TenantCtx["tenant"] }> = async (ctx, next) => {
const tenant = await getTenantForUser(ctx.user.id);
return next({ ...ctx, tenant });
};
// Rate limit middleware: TenantCtx → TenantCtx (no enrichment, may short-circuit)
const withRateLimit: Middleware<TenantCtx, TenantCtx> = async (ctx, next) => {
const allowed = await checkRateLimit(ctx.tenant.id, ctx.tenant.plan);
if (!allowed) {
throw new HttpError(429, "Rate limit exceeded");
}
return next(ctx);
};Because each middleware is a plain async function, you can test them individually without spinning up a server, seeding a database, or mocking an entire request object.
Error Handling Without Leaking Details
Short-circuiting the pipeline on error is the right default behavior, but you need one place to translate middleware errors into HTTP responses. That's your outer error boundary — and it belongs in the framework adapter, not inside individual middleware.
type HttpError = { status: number; message: string };
function isHttpError(e: unknown): e is HttpError {
return typeof e === "object" && e !== null && "status" in e && "message" in e;
}
// Framework adapter: wraps your typed pipeline in a Next.js route handler
function createRouteHandler<TCtx extends BaseCtx>(
pipeline: (ctx: TCtx) => Promise<TCtx>,
buildCtx: (req: Request) => TCtx,
respond: (ctx: TCtx) => Response,
): (req: Request) => Promise<Response> {
return async (req) => {
try {
const ctx = buildCtx(req);
const result = await pipeline(ctx);
return respond(result);
} catch (e) {
if (isHttpError(e)) {
return Response.json({ error: e.message }, { status: e.status });
}
console.error("[unhandled]", e);
return Response.json({ error: "Internal server error" }, { status: 500 });
}
};
}Business logic errors that should be 404 or 422 are HttpError instances. Infrastructure failures bubble up as unhandled exceptions and get caught at the boundary. The distinction is meaningful and deliberate.
Testing Middleware in Isolation
The real payoff of this design is unit-testable middleware. No supertest, no test server, just plain function calls.
describe("withRateLimit", () => {
it("calls next when within limit", async () => {
const ctx: TenantCtx = {
requestId: "test-1",
startedAt: Date.now(),
user: { id: "u1", roles: ["member"] },
tenant: { id: "t1", plan: "pro" },
};
vi.mocked(checkRateLimit).mockResolvedValue(true);
const next = vi.fn().mockImplementation((c) => Promise.resolve(c));
await withRateLimit(ctx, next);
expect(next).toHaveBeenCalledWith(ctx);
});
it("throws HttpError(429) when limit exceeded", async () => {
vi.mocked(checkRateLimit).mockResolvedValue(false);
const ctx: TenantCtx = {
requestId: "test-2",
startedAt: Date.now(),
user: { id: "u1", roles: ["member"] },
tenant: { id: "t1", plan: "free" },
};
await expect(withRateLimit(ctx, vi.fn())).rejects.toMatchObject({
status: 429,
});
});
});No mocking of req or res. No done callbacks. Just a function, an input, and an assertion.
Prefer passing dependencies explicitly (as constructor arguments or via a factory) rather than importing them directly inside middleware. It keeps the test setup simple and makes the dependency graph visible.
Assembling the Final Pipeline
Putting it together, a route handler reads like a declaration of its requirements.
const invoicesPipeline = compose(
withAuth,
withTenant,
withRateLimit,
);
export const GET = createRouteHandler(
invoicesPipeline,
(req) => ({
requestId: req.headers.get("x-request-id") ?? crypto.randomUUID(),
startedAt: Date.now(),
}),
async (ctx) => {
const invoices = await getInvoices(ctx.user.id, ctx.tenant.id);
return Response.json(invoices);
},
);The order is explicit and meaningful. The types enforce it. Adding a new stage — say, withAuditLog — means declaring what it needs, dropping it in the chain, and letting the compiler catch any gap in the context it expects.
Key Takeaways
- Framework middleware signatures hide contracts — typed context objects make the pipeline's invariants visible at compile time
- Compose as a primitive — a small
composefunction lets you build pipelines independent of any framework, making them portable and testable - Short-circuit with typed errors, handle them at the boundary — individual middleware should throw, not respond; one adapter per entry point translates errors to HTTP
- Test middleware as pure functions — no test server needed; just a context object, a mock
next, and an assertion - Reordering should be a compiler error, not a runtime surprise — if stage B requires what stage A produces, the TypeScript types enforce that order automatically


