Function Composition
Function composition is the art of building big behavior out of small, single-purpose functions by feeding the output of one into the input of the next. Instead of writing one tangled function that does five things, you write five tiny functions and connect them like sections of pipe. The result reads top-to-bottom as a description of what happens, the pieces stay independently testable, and you can rearrange the pipeline without rewriting the parts.
What composition actually means
Mathematically, composing f and g produces a new function that applies g first, then f: compose(f, g)(x) === f(g(x)). In plain code, you take a value and thread it through a sequence of transformations, each one taking exactly one argument and returning a result. The functions only need to agree on a contract: the output type of one must match the input type of the next.
const trim = (s) => s.trim();
const toLower = (s) => s.toLowerCase();
const exclaim = (s) => `${s}!`;
// Manual composition — nested calls, read inside-out
const shout = (s) => exclaim(toLower(trim(s)));
console.log(shout(" Hello World "));
Output:
hello world!
That nesting reads backwards (the last function listed runs first) and gets unwieldy fast. Helpers fix both problems.
Building compose and pipe
compose runs right-to-left (matching the math); pipe runs left-to-right (matching reading order). Both are one-liners built on reduce/reduceRight. The key insight: you reduce over the array of functions, threading the accumulated value through each one.
const compose = (...fns) => (x) => fns.reduceRight((acc, fn) => fn(acc), x);
const pipe = (...fns) => (x) => fns.reduce((acc, fn) => fn(acc), x);
const trim = (s) => s.trim();
const toLower = (s) => s.toLowerCase();
const exclaim = (s) => `${s}!`;
const shoutCompose = compose(exclaim, toLower, trim); // right-to-left
const shoutPipe = pipe(trim, toLower, exclaim); // left-to-right
console.log(shoutCompose(" Hello World "));
console.log(shoutPipe(" Hello World "));
Output:
hello world!
hello world!
Both produce the same result; pipe is usually friendlier because the steps are listed in the order they execute.
| Helper | Direction | helper(f, g, h)(x) evaluates as | Best when |
|---|---|---|---|
compose | right-to-left | f(g(h(x))) | mirroring math / FP convention |
pipe | left-to-right | h(g(f(x))) | reading like a step-by-step recipe |
Tip: Each function in the pipeline should take a single argument. If a step needs configuration, curry it (e.g.
multiplyBy(2)) so it still slots in as a unary function.
Point-free style
“Point-free” (or tacit) style means defining a function without naming its arguments — you describe it purely as a combination of other functions. Because pipe/compose return a function, you often never have to mention the data at all.
const pipe = (...fns) => (x) => fns.reduce((acc, fn) => fn(acc), x);
const split = (sep) => (s) => s.split(sep);
const map = (fn) => (arr) => arr.map(fn);
const join = (sep) => (arr) => arr.join(sep);
const capitalize = (w) => w[0].toUpperCase() + w.slice(1);
// No reference to the input string anywhere — point-free
const titleCase = pipe(
split(" "),
map(capitalize),
join(" ")
);
console.log(titleCase("the quick brown fox"));
Output:
The Quick Brown Fox
Notice how split, map, and join are curried so that calling them with their config returns a unary function ready for the pipeline. This is why currying and composition are such close partners.
Chaining transformations readably
Composition shines when you have a real data-shaping task. Compare an imperative version to a piped one — both compute the total price of in-stock items after a discount.
const pipe = (...fns) => (x) => fns.reduce((acc, fn) => fn(acc), x);
const products = [
{ name: "Keyboard", price: 80, inStock: true },
{ name: "Mouse", price: 40, inStock: false },
{ name: "Monitor", price: 300, inStock: true },
];
const onlyInStock = (items) => items.filter((p) => p.inStock);
const pricesOf = (items) => items.map((p) => p.price);
const sum = (nums) => nums.reduce((a, b) => a + b, 0);
const applyDiscount = (rate) => (total) => total * (1 - rate);
const checkoutTotal = pipe(
onlyInStock,
pricesOf,
sum,
applyDiscount(0.1)
);
console.log(checkoutTotal(products));
Output:
342
The pipeline is a readable summary of the algorithm: keep in-stock items, grab their prices, sum them, knock off 10%. Each stage is trivially unit-testable on its own, and reordering or inserting a step (say, a tax stage) is a one-line change.
Gotcha: A thrown error or a
null/undefinedflowing into a step that expects an object will crash the whole pipe. Keep each function defensive about its input, or compose error handling explicitly rather than relying on the chain to be forgiving.
Best Practices
- Keep every composed function unary (one argument) and pure so steps are interchangeable and predictable.
- Prefer
pipefor application code — left-to-right order matches how people read and debug. - Name intermediate functions well; the pipeline becomes self-documenting when each step reads like a verb phrase.
- Curry configurable steps (
multiplyBy(2),applyDiscount(0.1)) so they slot cleanly into a chain. - Don’t force point-free style when a named argument is clearer — readability beats cleverness.
- Reach for a battle-tested utility (Ramda’s
pipe/compose, orlodash/fp) instead of hand-rolling when you need richer features like async pipelines.