pipe in 5 lines
const pipe = (...fns) => (input) => fns.reduce((acc, fn) => fn(acc), input);
That is it. pipe(f, g, h)(x) becomes h(g(f(x))) but reads left-to-right, which matches how you think about the transformation.
Where I use it
Any data transformation with multiple stages:
const normalizeUser = pipe(
trimStrings,
lowercaseEmail,
removeNullFields,
addTimestamps,
);
const clean = normalizeUser(rawInput);
Each step is a tiny, pure, independently testable function. The pipeline itself is data — you can build, extend, or reorder it at runtime.
The class alternative
class UserNormalizer {
normalize(input) {
return this.addTimestamps(
this.removeNullFields(
this.lowercaseEmail(
this.trimStrings(input),
),
),
);
}
// four methods, all `this`-bound, nested pyramid
}
More code, reversed reading order, and now I have a class I will need to instantiate and possibly subclass. For a transform pipeline, nothing about a class earns its weight.
Async composition
pipe is synchronous by default. For async steps, use an async variant:
const pipeAsync = (...fns) => (input) =>
fns.reduce((p, fn) => p.then(fn), Promise.resolve(input));
const loadAndNormalizeUser = pipeAsync(
fetchUser,
fetchProfile,
normalizeUser,
);
const user = await loadAndNormalizeUser(userId);
Each step can be sync or async. await flows through naturally.
Composition in React
React embraces composition over inheritance explicitly. Higher-order components are pipe for components. Hooks are composable pieces of logic — useUser, usePermissions, useAuditLog snap together without inheritance. If you have ever felt the pull to subclass a React component, the answer is almost always "extract a hook."
Middleware as composition
Express, Koa, NestJS interceptors — middleware is composition with an escape hatch:
app.use(logger);
app.use(authenticate);
app.use(rateLimit);
app.use(handler);
Each middleware wraps the next. The request flows through in order. Swap an item, reorder, remove — no class hierarchy to untangle.
The limits of composition
For stateful, interactive objects — a WebSocket wrapper, a parser with accumulated buffer — a class or closure with methods is often clearer than a pipeline. Composition excels at transformations. It strains when the subject has identity and lifetime.
The refactor I keep doing
When I see a class with mostly static methods, or a class whose only state is passed through constructor arguments — I replace it with functions and a pipeline. The code shrinks, tests get simpler, and the "what does this do" reading experience improves. Inheritance has its place. It is just smaller than I used to think.
