Pipelines de middleware componibles en TypeScript
Deja de atornillar middleware a los frameworks: construye primitivas de pipeline componibles y tipadas que puedes probar aisladas y entender de un vistazo.

El middleware es uno de esos patrones que todo desarrollador backend usa constantemente y que casi nadie diseña de forma deliberada. Heredas una app de Express con cuarenta llamadas a app.use(), o un proyecto de Next.js donde cada ruta reimplementa la autenticación y el logging de forma distinta, y lo aceptas como el estado natural de las cosas. No tiene por qué ser así.
Construir middleware como primitivas componibles y tipadas —en lugar de callbacks de framework atornillados entre sí— hace que los pipelines sean testeables de forma aislada, reordenables sin miedo y legibles como flujo de datos en lugar de efectos secundarios.
El problema con el middleware nativo de los frameworks
El middleware de Express tiene una firma que se ha copiado por todas partes: (req, res, next) => void. Es flexible, pero esa flexibilidad es el problema. Nada en el sistema de tipos te dice qué espera encontrar un middleware en req, qué le añade, o si siquiera llamará a next.
// ❌ 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);
});El middleware se ejecutó —probablemente— pero no hay ninguna garantía en tiempo de compilación. Si reordenas esas tres líneas u omites una, obtienes un fallo en tiempo de ejecución y un ingeniero de guardia confundido a las 2 de la madrugada.
Definiendo un pipeline con contexto tipado
La solución es hacer explícito el tipo del contexto en cada paso. Una función de middleware transforma un objeto de contexto de una forma a otra, o cortocircuita con un 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>;Esta firma significa que el compilador impone que cada etapa reciba exactamente lo que necesita. Si enforceRateLimits necesita una propiedad user en el contexto, simplemente tipa su entrada en consecuencia —y la compilación falla si lo colocas antes de requireAuth.
Construyendo la función Compose
Componer una lista de middleware en un único manejador es sencillo una vez que los tipos están bien definidos.
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);
};
}Este es el mismo modelo de cebolla que Koa usa internamente, pero extraído y tipado de forma independiente de cualquier framework. Puedes usarlo en una ruta de Next.js, un handler de Lambda, un procesador de trabajos en segundo plano o un test unitario puro.
Implementaciones concretas de middleware
Así es como se ve el middleware real en este modelo —cada etapa declara lo que necesita y lo que añade.
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);
};Como cada middleware es una simple función async, puedes testearlos individualmente sin levantar un servidor, poblar una base de datos o simular un objeto de request completo.
Manejo de errores sin filtrar detalles
Cortocircuitar el pipeline ante un error es el comportamiento por defecto correcto, pero necesitas un único lugar para traducir los errores del middleware a respuestas HTTP. Ese es tu límite de error exterior —y pertenece al adaptador del framework, no al interior de cada 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 });
}
};
}Los errores de lógica de negocio que deberían ser 404 o 422 son instancias de HttpError. Los fallos de infraestructura burbujean como excepciones no manejadas y se capturan en el límite. La distinción es significativa y deliberada.
Testeando middleware de forma aislada
La verdadera recompensa de este diseño es el middleware testeable unitariamente. Sin supertest, sin servidor de pruebas, solo llamadas a funciones simples.
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,
});
});
});Sin simulacros de req o res. Sin callbacks done. Solo una función, una entrada y una aserción.
Prefiere pasar las dependencias explícitamente (como argumentos del constructor o mediante una factoría) en lugar de importarlas directamente dentro del middleware. Mantiene la configuración de los tests simple y hace visible el grafo de dependencias.
Ensamblando el pipeline final
Juntándolo todo, un manejador de ruta se lee como una declaración de sus requisitos.
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);
},
);El orden es explícito y significativo. Los tipos lo imponen. Añadir una nueva etapa —digamos, withAuditLog— significa declarar lo que necesita, insertarla en la cadena y dejar que el compilador detecte cualquier hueco en el contexto que espera.
Conclusiones clave
- Las firmas de middleware de los frameworks ocultan contratos — los objetos de contexto tipados hacen visibles los invariantes del pipeline en tiempo de compilación
- Compose como primitiva — una pequeña función
composete permite construir pipelines independientes de cualquier framework, haciéndolos portables y testeables - Cortocircuita con errores tipados, manéjalos en el límite — cada middleware debería lanzar errores, no responder; un adaptador por punto de entrada traduce los errores a HTTP
- Testea el middleware como funciones puras — no se necesita servidor de pruebas; solo un objeto de contexto, un
nextsimulado y una aserción - Reordenar debería ser un error del compilador, no una sorpresa en tiempo de ejecución — si la etapa B requiere lo que produce la etapa A, los tipos de TypeScript imponen ese orden automáticamente


