Implementing compose
compose() is a combinator that assembles several functions into one: compose(f, g, h)(x) is equivalent to f(g(h(x))) and runs them right to left. The whole implementation boils down to folding a list of functions: reduceRight for compose and reduce for its mirror image pipe.
Theory
TL;DR
compose(f, g, h)(x)equalsf(g(h(x))), executing right to left.- The core implementation is
fns.reduceRight((v, fn) => fn(v), x). - To let the last function in the chain take several arguments, keep the accumulator in an array and unpack it with spread.
- The async variant wraps the first result in
Promise.resolveand stitches the rest together with.then. - In TypeScript you write overloads for each arity, because a general variadic type is hard to infer.
- In production it is handy to have both:
compose(right to left) andpipe(left to right), plus their async versions.
Quick example
const compose = (...fns) => x => fns.reduceRight((v, fn) => fn(v), x);
const trim = s => s.trim();
const toInt = s => parseInt(s, 10);
const inc = n => n + 1;
const parseAndInc = compose(inc, toInt, trim);
console.log(parseAndInc(' 41 ')); // 42The string is trimmed first, then parsed into a number, and only then incremented: the functions read right to left.
Minimal implementation (ES6)
A generic version in which the last handler can take many arguments:
// Generic version: the last handler can take many arguments
const compose = (...fns) => (...args) =>
fns.reduceRight(
(acc, fn) => [fn(...acc)],
args
)[0];
// Example
const trim = s => s.trim();
const toInt = s => parseInt(s, 10);
const inc = n => n + 1;
const parseAndInc = compose(inc, toInt, trim);
parseAndInc(' 41 '); // 42The trick is that the accumulator is always an array of arguments. The seed value is args, and every step returns [fn(...acc)], that is an array of one element again. At the end you just take [0].
A variant for unary functions
If every function takes exactly one argument, the array wrapper is redundant and the implementation becomes shorter and slightly faster:
const composeUnary = (...fns) => x =>
fns.reduceRight((v, fn) => fn(v), x);This is the most common form: in functional code chains are almost always made of unary transformations.
Promise-aware compose
The async version works with both sync and async functions:
const composeAsync = (...fns) => (...args) =>
fns
.slice(0, -1)
.reduceRight(
(p, fn) => p.then(res => fn(res)),
Promise.resolve(fns[fns.length - 1](...args))
);
// Example
const fetchUser = async id => ({ id, name: 'Tim' });
const getName = u => u.name;
const shout = s => s.toUpperCase();
const getUserNameLoud = composeAsync(shout, getName, fetchUser);
getUserNameLoud(7).then(console.log); // 'TIM'The last function in the list (fetchUser) is called separately and its result is immediately normalised with Promise.resolve. The remaining functions are attached with .then, so each one receives an already unwrapped value, whether it is sync or async.
A type-safe version for TypeScript
A general type for a variadic function is hard to infer, so in practice people write overloads. The ones below cover up to 4 functions and can be extended the same way.
type Unary<A, R> = (a: A) => R;
export function compose<A, R>(f1: Unary<A, R>): Unary<A, R>;
export function compose<A, B, R>(
f1: Unary<B, R>,
f2: Unary<A, B>
): Unary<A, R>;
export function compose<A, B, C, R>(
f1: Unary<C, R>,
f2: Unary<B, C>,
f3: Unary<A, B>
): Unary<A, R>;
export function compose<A, B, C, D, R>(
f1: Unary<D, R>,
f2: Unary<C, D>,
f3: Unary<B, C>,
f4: Unary<A, B>
): Unary<A, R>;
export function compose(...fns: Function[]) {
return (x: unknown) => fns.reduceRight((v, f) => f(v), x);
}The overloads stitch the types of neighbouring functions together: the output of f2 must match the input of f1, otherwise the compiler flags it right away.
pipe and useful notes
You often need pipe() as well, which runs functions left to right:
const pipe = (...fns) => (...args) =>
fns.reduce((acc, fn) => [fn(...acc)], args)[0];
const parseAndInc2 = pipe(trim, toInt, inc);
parseAndInc2(' 41 '); // 42- If you compose object methods, do not forget
this: bind them withfn.bind(obj). - Try to keep functions pure and unary: composition then stays simpler and more predictable.
- In production it is convenient to have both:
compose(right to left) andpipe(left to right), pluscomposeAsync/pipeAsync.
Common mistakes
- Reaching for
reduceinstead ofreduceRight. That gives youpipe, notcompose, and the chain runs in reverse. - Forgetting the seed value in
reduce. Without it the first accumulator is a function from the list rather than the input data. - Expecting a plain
composeto unwrap a Promise. The sync version passes the Promise itself along; you needcomposeAsync. - Losing
thison methods.compose(obj.method)calls the method without its context; useobj.method.bind(obj). - Counting on several arguments in the middle of the chain. Only the last (rightmost) function takes multiple arguments; every later step receives exactly one value.
- Composing impure functions. Side effects in the middle of a chain make the result depend on call order and timing.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.