Function Composition and Pipelines
Function composition is one of those ideas that, once it clicks, changes how you write code. Instead of cramming logic into a single function, you build pipelines from small, reusable pieces that do one thing well. This tutorial covers the mechanics of composition in JavaScript, how compose and pipe differ, and where these patterns show up in real codebases.
The Core Idea: Building Functions from Functions
Composition means combining two or more functions to produce a new one. If you have a function that formats a string and another that validates it, you can compose them together rather than write one monolithic function that does both.
The fundamental rule is straightforward: when you compose f and g, applying the result to a value x means applying g first, then f to the result. In notation, compose(f, g)(x) equals f(g(x)). The rightmost function runs first, and each subsequent function wraps around the previous result.
Here is the minimal implementation:
const compose = (...fns) => x => fns.reduceRight((acc, fn) => fn(acc), x);
const pipe = (...fns) => x => fns.reduce((acc, fn) => fn(acc), x);
reduceRight runs the functions from right to left. reduce runs them left to right. That single difference gives each helper its personality.
compose vs. pipe: Choosing a Mental Model
compose and pipe do the same job. They thread a value through a chain of functions. The difference is which direction you read them in.
With compose, the rightmost function is applied first. Reading compose(f, g, h)(x) as a chain feels like peeling from the outside in. You end up writing f(g(h(x))) in your head.
With pipe, the leftmost function runs first. pipe(f, g, h)(x) reads top-to-bottom like a recipe: do f to x, then pass the result to g, then to h. This matches how people naturally describe transformations.
const { pipe } = require('lodash/fp');
const transform = pipe(
s => s.trim(), // first: remove whitespace
s => s.toLowerCase(), // second: lowercase
s => s.replace(/\s+/, '-') // third: replace spaces with dashes
);
transform(' Hello World '); // 'hello-world'
Use pipe when you are reading the code like a sequence of steps. Use compose when you are working in a mathematical tradition or when the right-to-left reading feels more natural for your domain. Neither is objectively better. Consistency within a codebase matters more than the choice between them.
Composition is Already in Your Code
You do not need compose or pipe to benefit from composition. Chained array methods are composition in disguise.
const result = users
.filter(u => u.active)
.map(u => u.name)
.map(name => name.trim())
.filter(name => name.length > 0);
Each method call passes its output to the next. That is function composition with extra syntax. The composed form becomes useful when you want to extract and reuse the pipeline as a named function:
const getActiveUserNames = pipe(
arr => arr.filter(u => u.active),
arr => arr.map(u => u.name),
arr => arr.map(name => name.trim()),
arr => arr.filter(name => name.length > 0)
);
const names = getActiveUserNames(users);
const editors = getActiveUserNames(editorsList);
Now the transformation has a name and can be reused across different datasets without repeating the logic.
Point-Free Style: When Arguments Disappear
Point-free (tacit) programming defines functions without explicitly naming their arguments. The data flow is implicit in the composition chain.
// Explicit arguments
const doubleAll = arr => arr.map(x => x * 2);
const evensOnly = arr => arr.filter(x => x % 2 === 0);
const sum = arr => arr.reduce((a, b) => a + b, 0);
const totalEvens = arr => sum(evensOnly(doubleAll(arr)));
// Point-free
const totalEvens = pipe(
doubleAll,
evensOnly,
sum
);
The second version omits the arr parameter because the pipe already threads the input through each function. This works well when every function in the chain takes one argument and returns one result.
Point-free style has a cost. When a transformation has multiple responsibilities or complex branching logic, named arguments actually improve readability. Forcing point-free on messy logic produces harder-to-follow code. Save it for clean, linear transformations where the intent is obvious from the function names.
Real Pipelines: Validators, Formatters, and Middleware
Composition shines for building processing pipelines that need to be readable and composable.
Validators that thread errors through a chain:
const isNonEmpty = str => str.trim().length > 0;
const isEmail = str => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str);
const isLongEnough = str => str.length >= 8;
const validateRegistration = pipe(
v => v.email ? null : 'Email is required',
v => v === null ? null : (isEmail(v) ? null : 'Invalid email'),
v => v === null ? null : (v.password && isLongEnough(v.password) ? null : 'Password too short')
);
const result = validateRegistration({ email: 'alice@example.com', password: 'secret' });
// null — valid
Formatters that sanitize content in stages:
const stripTags = html => html.replace(/<[^>]*>/g, '');
const escapeHtml = str => str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
const normalizeWhitespace = str => str.replace(/\s+/g, ' ').trim();
const sanitizeText = pipe(
stripTags,
escapeHtml,
normalizeWhitespace
);
sanitizeText('<p>Hello & goodbye</p>'); // 'Hello & goodbye'
Middleware that processes requests in order:
const parseBody = req => ({ ...req, body: JSON.parse(req.body || '{}') });
const authenticate = req => req.user ? req : { ...req, error: 'Unauthorized' };
const logRequest = req => { console.log(`${req.method} ${req.path}`); return req; };
const handleRequest = pipe(parseBody, authenticate, logRequest);
Partial Application Feeds Composition
Partial application lets you fix some arguments of a function upfront, returning a function that takes the rest. This creates specialized versions that slot neatly into composition chains.
const partial = (fn, ...presetArgs) => (...laterArgs) => fn(...presetArgs, ...laterArgs);
const makeRequest = (method, url, headers, body) => { /* ... */ };
const post = partial(makeRequest, 'POST');
const withAuth = (req, token) => ({ ...req, headers: { ...req.headers, Authorization: `Bearer ${token}` } });
const apiPost = pipe(
partial(makeRequest, 'POST', 'https://api.example.com'),
req => withAuth(req, 'my-token')
);
The reusable pieces (post, withAuth, the partially applied URL) compose cleanly without needing new wrapper functions for every combination.
Error Handling Needs Explicit Design
Composition has no built-in error handling. If any function in the chain throws, the entire composition throws. For production code, consider wrapping the pattern in a result type.
const Right = x => ({ ok: true, value: x });
const Left = x => ({ ok: false, error: x });
const tryParse = str => {
try { return Right(JSON.parse(str)); }
catch (e) { return Left(e.message); }
};
const getName = result =>
result.ok ? Right(result.value.name) : result;
const getNameSafe = pipe(tryParse, getName);
getNameSafe('{"name": "Alice"}'); // { ok: true, value: 'Alice' }
getNameSafe('not json'); // { ok: false, error: 'Unexpected token n in JSON' }
The Either pattern threads success and failure through the composition without throwing. This makes error handling explicit and composable rather than scattered across try/catch blocks.
Performance: Not the Concern You Think It Is
Composition creates a new closure on every call. For most applications, this is immeasurably small. Creating a closure and calling one extra function adds microseconds at most. V8 optimizes closures aggressively.
Only reach for optimization when composition sits in a tight loop running thousands of iterations per frame. In virtually all other cases, the readability and maintainability benefits far outweigh the negligible overhead.
Conclusion
Function composition is about building behavior from small, single-purpose pieces. compose and pipe are the two main tools for threading data through a chain of functions. Choose pipe for top-to-bottom readability, compose for right-to-left mathematical style. Chain array methods for simple cases; reach for pipe or compose when you need to name and reuse the pipeline. Combine with partial application for powerful reusable building blocks.
See Also
- /tutorials/fp-higher-order-functions/ — Higher-order functions are the building blocks composition relies on.
- /tutorials/fp-immutability/ — Immutability pairs well with composition to avoid shared state bugs.
- /tutorials/js-functions-and-scope/ — Understanding scope and closures is essential before mastering composition.