Chain of Responsibility — The Middleware Shape
This chapter covers composing middleware and passing context through types.
The problem this pattern set out to solve
GoF catalogs Chain of Responsibility as a pattern that hands a request off along a chain of handling objects1.
The sender does not know who will handle it and only passes the request to the head of the chain. Each link decides whether to handle it or pass it on. It is still widely used for lining up concerns you want slotted in before and after the real work, such as authentication, logging, and rate limiting.
The name middleware has become more current than the pattern's own. The two are not exactly the same, though. Chain of Responsibility is the shape where a request moves along the chain and stops once some receiver handles it1. A post-processing step does not appear in that shape. Middleware can come back after calling the next link and keep working. The accurate reading is that the hand-off skeleton is shared and the handling of the return trip is added on top.
Writing it straightforwardly with classes
You make each link a class and give it a reference to the next one.
type AppRequest = {url: string; headers: Record<string, string>};
abstract class Handler {
private nextHandler: Handler | undefined;
setNext(handler: Handler): Handler {
this.nextHandler = handler;
return handler; // Returns the next one to make chaining easier to write
}
handle(request: AppRequest): Response {
if (this.nextHandler === undefined) {
return new Response('not handled', {status: 404});
}
return this.nextHandler.handle(request);
}
}
class AuthHandler extends Handler {
override handle(request: AppRequest): Response {
if (request.headers.authorization === undefined) {
return new Response('unauthorized', {status: 401});
}
return super.handle(request);
}
}
class LoggingHandler extends Handler {
override handle(request: AppRequest): Response {
console.log(`-> ${request.url}`);
return super.handle(request);
}
}
// The real handling sits at the end of the chain. Forget to place it and you get a 404
class AppHandler extends Handler {
override handle(_request: AppRequest): Response {
return new Response('ok');
}
}
const auth = new AuthHandler();
auth.setNext(new LoggingHandler()).setNext(new AppHandler());
const response = auth.handle({url: '/articles', headers: {authorization: 'Bearer x'}});
It works, but the chain is assembled as mutable state. The order of setNext calls decides the behavior, and the resulting arrangement never appears in the type.
Replacing it with language features
You make each link a function that takes the context and the next link. This is the shape widely used by Node.js frameworks.
type AppRequest = {url: string; headers: Record<string, string>};
type Next = () => Response;
type Middleware = (request: AppRequest, next: Next) => Response;
const auth: Middleware = (request, next) =>
request.headers.authorization === undefined
? new Response('unauthorized', {status: 401})
: next();
const logging: Middleware = (request, next) => {
console.log(`-> ${request.url}`);
return next();
};
function compose(middlewares: Middleware[], final: (request: AppRequest) => Response) {
return (request: AppRequest): Response => {
const run = (index: number): Response =>
index === middlewares.length
? final(request)
: middlewares[index](request, () => run(index + 1));
return run(0);
};
}
const handle = compose([auth, logging], () => new Response('ok'));
const response = handle({url: '/articles', headers: {authorization: 'Bearer x'}});
The abstract class and setNext are gone, and the arrangement is written in one place as an array literal. Assembly became an expression, so the order reads right there.
When a step adds to the context
Middleware often adds information for what follows — passing userId downstream once authentication succeeds, for instance. This is where types can do work.
type AppRequest = {url: string; headers: Record<string, string>};
// Lets the context type the next step receives change from step to step
type Step<In, Out> = (context: In, next: (context: Out) => Response) => Response;
const auth: Step<AppRequest, AppRequest & {userId: string}> = (context, next) => {
const token = context.headers.authorization;
if (token === undefined) {
return new Response('unauthorized', {status: 401});
}
return next({...context, userId: token.replace('Bearer ', '')});
};
const handle = (request: AppRequest): Response =>
auth(request, (context) => {
// Here the type shows that userId is definitely present
return new Response(`hello ${context.userId}`);
});
const response = handle({url: '/articles', headers: {authorization: 'Bearer u1'}});
The type knows that the context after passing through auth carries userId. Try to read context.userId from code that runs before authentication and it stops at compile time. The class version in this chapter does not express that build-up — expressing it would mean giving Handler a type parameter, which lands on the same story as the function version.
How this series judges it
Verdict: language features can replace it. In TypeScript there is no visible reason to make each link a class and connect them with setNext. Functions and an array come out shorter, and the arrangement reads as an expression.
On top of that, whether to build the context type up step by step is the practical dividing line.
- Steps that add nothing to the context (logging, metrics, rate limiting) — the
Middlewareshape above is enough. Every step sees the same context type. - Steps that add to the context (authentication, tenant resolution, body parsing) — use the
Step<In, Out>shape so the type the next step receives changes per step.
What would overturn this judgment
Building types up falls apart when you run it through a naive composition function. Receive the steps as an array of a single type, as in compose(middlewares: Middleware[]), and there is no way to express a different type per step as the element type, so they collapse into a common type. That is why the example above calls auth directly and nests instead.
This is not a limit of the language. Combining variadic tuple types with recursive conditional types lets you write a composition function that threads per-step types while keeping the array (the type definitions run long, so this chapter does not show them). Some libraries connect steps with a method chain (.use(auth).use(tenant)), which also avoids deep nesting. The cost in either case is that the type definitions holding the mechanism up get hard to read.
The choice is between building types up and keeping the composition's type definitions readable, not a case where the two cannot coexist.
One more thing: when asynchronous steps are in the mix, unify the return type as Promise<Response>. Mixing synchronous and asynchronous steps makes the handling of next()'s return value vary from step to step, which reads poorly.
How this relates to existing articles
- Designing the Presentation Layer touches on Laravel's middleware. That article is about using a mechanism a PHP framework provides; this chapter is about assembling the same structure yourself in TypeScript. Both the language and the target differ.
- Advanced functions covers how to write higher-order functions.
- Decorator also wraps with functions, but that chapter adds behavior to a single operation while this one runs through several steps in order.