Implement compose(middlewares) — an async middleware composer in the style of Koa. Each middleware is a function that receives a shared context object and a next continuation. Calling next() descends into the next middleware in the chain; awaiting next() lets the current middleware run code AFTER the downstream chain finishes. That downstream-then-upstream traversal is the "onion model" — control flows IN through every layer to the centre, then back OUT in reverse order.
The composer is the foundational primitive behind modern web frameworks: routing, auth, logging, error handling, body parsing, and rate limiting are all expressed as middlewares that plug into the same shape. Get this one function right and you have the kernel of a framework.
type Middleware<Ctx> = (context: Ctx, next: () => Promise<void>) => Promise<void> | void;
function compose<Ctx>(
middlewares: Array<Middleware<Ctx>>
): (context: Ctx, next?: () => Promise<void>) => Promise<void>;
Basic three-middleware run, showing the onion order:
const ctx = { log: [] };
const mw = (name) => async (c, next) => {
c.log.push(`${name} before`);
await next();
c.log.push(`${name} after`);
};
await compose([mw('A'), mw('B'), mw('C')])(ctx);
// ctx.log === ['A before', 'B before', 'C before',
// 'C after', 'B after', 'A after']
Conditional descent — a middleware decides whether to call next():
const auth = async (c, next) => {
if (!c.user) return; // short-circuit: downstream never runs
await next();
};
await compose([auth, async (c) => { c.log.push('protected'); }])({ user: null, log: [] });
// log is [] — auth gated the chain
Top-level error handler via try/catch around await next():
const ctx = { log: [] };
await compose([
async (_c, next) => {
try { await next(); } catch (err) { ctx.log.push(`caught: ${err.message}`); }
},
async () => { throw new Error('boom'); },
])(ctx);
// ctx.log === ['caught: boom'] — composed promise resolved, not rejected
Calling next() twice in the same middleware is a hard error:
await compose([
async (_c, next) => { await next(); await next(); },
async () => {},
])({});
// rejects: Error('next() called multiple times')
next() at most once. A second call rejects the composed promise with Error('next() called multiple times'). This catches the common bug where a middleware accidentally falls through to next() twice (e.g. after an error-handling branch that forgets to return).await next() — a sync throw or async rejection anywhere downstream surfaces in the awaiting parent. Wrap await next() in try/catch to handle it. If no middleware catches the error, it rejects the composed promise.[A, B, C] runs A-before, B-before, C-before, C-after, B-after, A-after. Skip next() and the chain stops at that layer; upstream parents that already awaited still resume normally because the skipping middleware simply returned.context object. Mutations are visible to siblings. Treat it like Koa's ctx.compose([outer, compose([inner1, inner2])]) is fine. The outer chain's next becomes the inner composed function's outerNext argument.void) or async. Sync throws become rejections automatically.context is just a plain object. Tests use { log: [] } so middlewares can push to it.compose should throw a TypeError synchronously if middlewares is not an array or any element is not a function.Unlock the solution & editor
Runnable editor + tests
Solve in the browser with instant Jest feedback.
Detailed solutions
Walkthroughs, edge cases, and complexity notes.
Multi-framework variants
React, Vue, Vanilla, Angular — same question, different stacks.