Typing the Chain of Responsibility Pattern in TypeScript — A Practical Guide
Introduction
Chain of Responsibility (CoR) is a behavioral design pattern that delegates requests along a chain of handlers until one of them processes it. In JavaScript/TypeScript codebases, CoR appears frequently as middleware stacks, event pipelines, or layered validations. For intermediate developers, the core challenges are: how to structure the chain, how to make each handler composable, and — critically — how to type the interactions so the compiler helps you avoid runtime errors.
In this tutorial you'll learn practical strategies for typing Chain of Responsibility implementations in TypeScript. We cover basic typed implementations, generics for request/response types, composing synchronous and asynchronous handlers, integrating runtime guards and assertion functions, and advanced optimizations like short-circuiting, caching, and safe mutation practices. You'll also see how CoR compares and interoperates with related patterns (Strategy, Command, Middleware), and how to choose the right abstraction for your problem.
By the end of this guide you will be able to:
- Design a strongly typed handler interface that fits both sync and async scenarios.
- Compose handler chains with type-safe signatures and clear ownership of request/response shaping.
- Add runtime validation while preserving type information using assertion functions and type predicates.
- Build factories and higher-order helpers for creating and reusing handlers safely.
This article assumes you already know TypeScript basics (interfaces, generics, union types) and are comfortable reading and writing modern TypeScript code.
Background & Context
Chain of Responsibility routes a request through a sequence of handlers. Each handler decides whether to handle the request, modify it, stop the chain, or pass it to the next handler. The pattern decouples senders from receivers and enables flexible composition of behaviors. In web stacks, CoR manifests as middleware: logging, authentication, validation, routing. In domain code, it helps build layered business rules.
Typing CoR matters because untyped handler chains hide mismatches in request/response shapes and sequencing errors (e.g., expecting a field that a prior handler should add). TypeScript can make the API clear: what a handler may accept, what it guarantees to return, and whether it short-circuits the chain. We’ll explore typing patterns that handle these concerns while staying ergonomic for developers.
CoR overlaps with other patterns: the Strategy pattern chooses an algorithm (we link to a detailed guide on typing Strategy pattern implementations), while Command encapsulates executable actions (Typing Command Pattern Implementations in TypeScript). Understanding these relationships helps pick the right abstraction for your design.
Key Takeaways
- Clear handler interfaces reduce runtime surprises and make composition safer.
- Generics let you express request and response evolution through the chain.
- Asynchronous and synchronous handlers can coexist with consistent typing.
- Runtime guards and assertion functions preserve type-safety when validating external inputs (Using Assertion Functions in TypeScript (TS 3.7+)).
- Higher-order helpers (middleware factories) simplify reuse and encourage testability (Typing Higher-Order Functions in TypeScript — Advanced Scenarios).
Prerequisites & Setup
You should have Node.js and TypeScript installed (TS 4.x or newer recommended). Create a simple project and tsconfig with strict mode for the best experience:
- Node.js (v12+), npm/yarn
- TypeScript (v4+)
- A code editor with TypeScript support
Install a dev environment:
mkdir ts-cor && cd ts-cor npm init -y npm install -D typescript @types/node npx tsc --init
Turn on "strict": true in tsconfig for the strongest compiler assistance. From here, open an editor and create .ts files for the examples in this tutorial.
Main Tutorial Sections
1) Minimal typed handler interface (sync)
Start with a simple, typed handler interface for synchronous flows. The core is a Handler that receives a request and optionally forwards to the next handler.
interface Handler<R = unknown, S = unknown> {
handle(request: R, next: (req: R) => S): S;
}
function composeSync<R, S>(handlers: Handler<R, S>[], finalFn: (req: R) => S): (req: R) => S {
return (req: R) => {
let idx = 0;
const next = (r: R): S => {
const h = handlers[idx++];
if (!h) return finalFn(r);
return h.handle(r, next);
};
return next(req);
};
}This pattern provides a typed pipeline. The generic types R and S denote the request and response. In simple cases you can set S = R or S = void. ComposeSync returns a function with clear input and output types.
2) Supporting asynchronous handlers
Real systems use I/O. Extend the interface to support Promise-based handlers while preserving types.
interface AsyncHandler<R = unknown, S = unknown> {
handle(request: R, next: (req: R) => Promise<S>): Promise<S>;
}
function composeAsync<R, S>(handlers: AsyncHandler<R, S>[], finalFn: (req: R) => Promise<S>) {
return async (req: R): Promise<S> => {
let idx = 0;
const next = async (r: R): Promise<S> => {
const h = handlers[idx++];
if (!h) return finalFn(r);
return h.handle(r, next);
};
return next(req);
};
}This design keeps a single code path signature: the composed function returns Promise. It's easy to mix sync finalFn implementations by wrapping them in Promise.resolve.
3) Mixing sync and async handlers safely
When architecting a chain, you often want to mix sync and async handlers. One option is to normalize everything to async and wrap sync results. Another is to create overloads but keeping runtime normalization is simpler.
type MaybePromise<T> = T | Promise<T>;
interface MixedHandler<R = unknown, S = unknown> {
handle(request: R, next: (req: R) => MaybePromise<S>): MaybePromise<S>;
}
async function composeMixed<R, S>(handlers: MixedHandler<R, S>[], finalFn: (req: R) => MaybePromise<S>) {
return async (req: R): Promise<S> => {
let idx = 0;
const next = async (r: R): Promise<S> => {
const h = handlers[idx++];
if (!h) return await finalFn(r);
return await h.handle(r, next);
};
return next(req);
};
}This approach encourages uniform async behavior without penalizing synchronous handlers.
4) Chaining and modifying request/response types with generics
Chains often mutate the request or enrich the context. Use generic transformer handlers to express these changes and preserve type safety.
type Next<T> = (req: T) => Promise<any>;
interface Transformer<I, O> {
handle(req: I, next: Next<O>): Promise<any>;
}
// Example: A validation handler transforms a raw input into a validated payload type
interface RawInput { raw: string }
interface ValidPayload { id: number; raw: string }
const validateHandler: Transformer<RawInput, ValidPayload> = {
async handle(req, next) {
const id = parseInt(req.raw, 10);
if (Number.isNaN(id)) throw new Error('invalid');
return next({ id, raw: req.raw });
}
};Generics let you compose handlers that transform the payload, but composition helpers must align input/output types carefully. Consider building typed factories (see Typing Factory Pattern Implementations in TypeScript) to produce these handlers consistently.
5) Short-circuiting, stopping the chain, and return types
A handler may fully handle a request and prevent further handlers from running. Model short-circuiting explicitly using discriminated unions.
type Continue<T> = { type: 'continue'; payload: T };
type Done<R> = { type: 'done'; result: R };
type Outcome<T, R> = Continue<T> | Done<R>;
interface ShortCircuitHandler<I, O, R> {
handle(req: I, next: (p: I) => Promise<Outcome<O, R>>): Promise<Outcome<O, R>>;
}This approach keeps the compile-time guarantee that if a handler returns Done, no further processing occurs and the final result is available immediately.
6) Runtime validation with assertion functions and type predicates
External inputs often require runtime checks. Use TypeScript assertion functions to narrow types after validation while keeping runtime error behavior consistent. See our guide on Using Assertion Functions in TypeScript (TS 3.7+) for patterns. Example:
function assertValidInput(x: unknown): asserts x is { name: string } {
if (!x || typeof x !== 'object' || typeof (x as any).name !== 'string') {
throw new Error('Invalid input');
}
}
const assertHandler: MixedHandler<unknown, { name: string }> = {
async handle(req, next) {
assertValidInput(req);
return next(req);
}
};If your chain uses filters, combine assertion functions with type predicates for filtering arrays to keep filtering type-safe.
7) Creating reusable middleware with higher-order helpers
Handlers are easier to reuse when they are higher-order functions that accept configuration and return typed handlers. See advanced typing for higher-order functions in Typing Higher-Order Functions in TypeScript — Advanced Scenarios.
function createLogger(prefix: string) {
return {
async handle(req: any, next: (r: any) => Promise<any>) {
console.log(prefix, req);
return next(req);
}
};
}
const logger = createLogger('REQ:');Higher-order helpers make it easy to parameterize behavior (timeouts, retries, logging levels) while keeping strong types.
8) Factories, dependency injection, and testability
Factories are useful for assembling complex handler stacks with dependencies. Combine typed factories with our Factory pattern guide to create handlers that receive dependencies in a typed manner.
type Repo = { find: (id: number) => Promise<any> };
function userHandlerFactory(repo: Repo) {
return {
async handle(req: { id: number }, next: any) {
req.user = await repo.find(req.id);
return next(req);
}
};
}Using factories improves testability: swap the repo for a mock without changing types.
9) Intercepting and caching results with Proxy-like patterns
Sometimes you want to transparently intercept calls or cache results for performance. Implement a handler that uses a cache and acts as an in-chain proxy. If you are interested in interception patterns and safe typing of proxies, our guide on Typing Proxy Pattern Implementations in TypeScript is useful.
function cacheHandler<T, R>(keyFn: (t: T) => string, cache: Map<string, R>) {
return {
async handle(req: T, next: (r: T) => Promise<R>) {
const key = keyFn(req);
if (cache.has(key)) return cache.get(key) as R;
const result = await next(req);
cache.set(key, result);
return result;
}
};
}This pattern avoids expensive recomputation while keeping types consistent.
10) Combining CoR with other patterns (Decorator, Strategy, Command)
CoR composes well with other patterns: decorate handlers with additional behavior (Typing Decorator Pattern Implementations (vs ES Decorators)), use Strategy for algorithm selection (typing Strategy), or wrap actions as Commands for queuing and undo semantics (Typing Command Pattern Implementations in TypeScript).
Example: wrap a handler with retry decorator:
function retryDecorator<T, R>(handler: MixedHandler<T, R>, times = 3): MixedHandler<T, R> {
return {
async handle(req, next) {
let lastErr: unknown;
for (let i = 0; i < times; i++) {
try {
return await handler.handle(req, next);
} catch (e) {
lastErr = e;
}
}
throw lastErr;
}
};
}Decorators implemented this way are testable and maintain clear type contracts.
Advanced Techniques
Once you have a working typed CoR, consider optimization and safety techniques. Use discriminated unions (Outcome types) for explicit short-circuit control to avoid ambiguous return shapes. Leverage mapped types and conditional types to create pipeline type transforms — for example, derive the pipeline input type from the first handler and the output from the final handler automatically with tuple processing. Use tuple variadic types (TS 4.0+) to strongly type compose functions that accept an ordered list of handlers where each transforms the payload shape. For performance, minimize object cloning: prefer shallow immutable updates via object spread only when needed, and consider caching handler results using deterministic keys illustrated in the caching example.
For runtime validation and safe narrowing, combine assertion functions and type predicates. Our guides on Using Assertion Functions in TypeScript (TS 3.7+) and Using Type Predicates for Filtering Arrays in TypeScript provide patterns and pitfalls to avoid.
If you need to generate handler instances dynamically, use typed factories or builders to enforce required dependencies at compile time (Typing Builder Pattern Implementations in TypeScript). For memoization of expensive handler computations, study typed memoization approaches in Typing Memoization Functions in TypeScript.
Best Practices & Common Pitfalls
- Prefer explicit types for handler inputs/outputs. Avoid implicit any in middleware stacks.
- Normalize to an async interface. Wrapping sync handlers in Promise.resolve keeps uniform signatures.
- Beware of silent mutations. Use documentation and types to indicate if a handler mutates request objects; prefer returning enriched copies where practical.
- Avoid overly generic Handler<any, any>. It defeats the purpose of typing. Narrow types at the boundaries and use composition helpers to adapt types when necessary.
- Use assertion functions for external input validation to keep narrowing consistent (Using Assertion Functions in TypeScript (TS 3.7+)).
- When composing transformers, ensure the output type of one handler matches the input type of the next. Mismatches are a common source of runtime errors; consider typed factories to glue components safely (Typing Factory Pattern Implementations in TypeScript).
- Test chains in isolation by mocking side-effecting dependencies. Factories and dependency injection simplify this.
- If performance is critical, measure the overhead of wrapping and Promise churn — sometimes a specialized fast-path sync compose is justified.
Real-World Applications
Chain of Responsibility is everywhere:
- HTTP middleware stacks (logging, auth, validation, routing).
- Event processing pipelines where messages are enriched or filtered in stages.
- Business rule engines where different rules decide ownership of a request.
- Input validation and transformation flows in CLI or GUI apps.
For caching and performance-sensitive handlers, see the cache/proxy patterns discussed earlier and our Typing Cache Mechanisms: A Practical TypeScript Guide for deeper strategies. You can also compose CoR with state machines for long-running processes; our Typing State Pattern Implementations in TypeScript touches on stateful transitions that complement chain-based processors.
Conclusion & Next Steps
A well-typed Chain of Responsibility improves maintainability, reduces runtime errors, and clarifies responsibilities between handlers. Start small: implement a typed async compose function, create a few handlers (validation, auth, business), and iterate by adding generics and assertion-based validations. Explore combining CoR with factories, decorators, and proxies to build robust, testable architectures.
Next steps:
- Implement a production middleware stack using the patterns above.
- Read the linked deeper guides about factories, decorators, assertion functions, and higher-order functions to level up your designs.
Enhanced FAQ
- What is the simplest typed CoR implementation for TypeScript?
The simplest is a Handler interface with generics for Request and Response, and a compose function that calls handlers sequentially. Use:
interface Handler<R, S> { handle(req: R, next: (r: R) => S): S }Then implement composeSync that returns (req: R) => S. Keep S = Promise
- How do you avoid mismatched types between handlers?
Use generics to express per-handler input/output and only compose handlers whose output type matches the input type of the next handler. If you build chains dynamically, enforce constraints using factory functions that assemble compatible handlers. Prefer explicit typing over any, and rely on TypeScript to detect mismatches. For complex pipelines, consider tuple-based variadic typing to derive the pipeline signature from handler tuple types.
- Can sync and async handlers coexist in the same pipeline?
Yes. Normalize to a MaybePromise
- How do you implement short-circuiting that preserves types?
Model outcomes with discriminated unions, e.g., Continue
- How should I validate external input in a handler without losing types?
Use assertion functions (asserts) or type predicates. Assertion functions let you throw on invalid input while telling TypeScript the narrowed type after the assertion. See Using Assertion Functions in TypeScript (TS 3.7+) for examples.
- Are there special typing techniques for middleware that transform payload shapes?
Yes. Express the transformation with generics where each handler defines Input and Output types. Compose helpers then need to ensure continuity: the output type of handler N must equal the input type of handler N+1. For advanced cases, use variadic tuple types to compute final signatures at compile time.
- How do I add caching without breaking purity and types?
Introduce a caching handler that keys requests deterministically and stores typed results in a Map. Use a typed key function and typed Map to avoid accidental type mixing. For more advanced caching patterns, read our Typing Cache Mechanisms: A Practical TypeScript Guide.
- Should I prefer mutation or returned enriched copies in a chain?
Prefer returning enriched copies to avoid hidden side effects unless performance requires in-place updates. If you must mutate, clearly document and type the mutation (e.g., extend interfaces) so downstream handlers expect the mutated shape. Consider using immutable helpers or structural types to show intent.
- How can I unit test handlers in isolation?
Use factories that take dependencies as parameters so you can inject mocks. Test each handler by invoking its handle method and passing a fake next function. This isolates side effects and verifies contracts without assembling the full pipeline. See the factory pattern guide for typed factories (Typing Factory Pattern Implementations in TypeScript).
- How does CoR compare to other patterns like Strategy or Command?
CoR is about passing a request through a chain where each may handle it; Strategy selects a single algorithm; Command encapsulates an action. You can combine them: use a Strategy inside a handler or encapsulate handler logic as Commands for queuing. For pattern comparisons and implementations, check our guides on typing Strategy pattern implementations and Typing Command Pattern Implementations in TypeScript.
- Any tips for advanced performance considerations?
Minimize Promise churn where possible, avoid deep cloning on every handler, and prefer memoization for repeated computations (Typing Memoization Functions in TypeScript). Also, keep your handler signatures as narrow as practical to enable inlining and better optimization by JS engines.
- Where can I learn more about typing related patterns?
Explore the linked guides on factories, builders, decorators, proxies, state, and higher-order functions. They complement CoR design decisions and help you reason about composition and type safety across your codebase. Some useful links from this article include:
- Typing Higher-Order Functions in TypeScript — Advanced Scenarios
- Using Assertion Functions in TypeScript (TS 3.7+)
- Typing Proxy Pattern Implementations in TypeScript
If you have an example scenario or code to type, paste it and we can iterate on a typed CoR solution tailored to your needs.
