skip to content

In JavaScript, write `compose` and `pipe` helpers that combine several single-argument functions into one. How do they differ, and what constrains the functions you can put in them?

level: middleimportance: should knowfreq 44%

answer

  1. both are folds over a function list
  2. reduce forward, reduceRight backward
  3. only one value travels between stages
  4. forgotten return poisons the chain
  5. a promise stage breaks a sync pipe

basics

~20 s

Both fold a list of functions into one. pipe applies left to right, compose right to left, matching mathematical notation. Each stage receives exactly one value — the previous stage's return value — so every function after the first must take one argument and return the input the next one expects.

solid answer

~50 s

`pipe` runs its functions in reading order and `compose` runs them in reverse, which is the only real difference: `pipe(f, g)(x)` is `g(f(x))` while `compose(f, g)(x)` is `f(g(x))`. Both are one-liners over a fold: `const pipe = (...fns) => (x) => fns.reduce((v, fn) => fn(v), x)` and the same with `reduceRight` for `compose`. The constraint is the wiring: only the first function sees the caller's arguments, and after that each stage gets a single value — the previous return value — so every function in the chain must be unary and its output type must match the next one's input. Two practical consequences: a stage returning `undefined` because someone forgot a `return` poisons everything downstream, and an async stage produces a promise that the next synchronous stage will treat as an ordinary object. `pipe` is generally preferred in application code because the reading order matches the execution order.

code

javascript · 13 lines
javascript
const pipe = (...fns) => (x) => fns.reduce((v, fn) => fn(v), x);
const compose = (...fns) => (x) => fns.reduceRight((v, fn) => fn(v), x);

const trim = (s) => s.trim();
const lower = (s) => s.toLowerCase();
const hyphenate = (s) => s.replaceAll(' ', '-');

console.log(pipe(trim, lower, hyphenate)('  Hello There  '));    // "hello-there"
console.log(compose(hyphenate, lower, trim)('  Hello There  ')); // "hello-there"

// a stage without a return poisons everything downstream
const broken = pipe(trim, (s) => { s.toLowerCase(); }, hyphenate);
try { broken('  Hello  '); } catch (e) { console.log(e.constructor.name); } // TypeError

go deeper

for a junior

Know that both helpers chain functions and that pipe runs left to right while compose runs right to left. Be able to write one of them with reduce.

for a middle

Explain the fold mechanics in both directions and the unary constraint that follows — only the first function sees the caller's arguments, and every later stage gets one value.

for a senior

Bring the operational angle: a forgotten return, an async stage, and unnamed frames in the stack trace all make pipelines harder to debug, and you should know when named intermediate steps beat composition.

for a principal

Own the codebase-level call: where a point-free pipeline style genuinely reduces duplication versus where it raises the reading cost for everyone, and what conventions (tap helpers, no async stages, one shape of data through the chain) make it safe to standardise on.

## Both are folds ```js const pipe = (...fns) => (x) => fns.reduce((value, fn) => fn(value), x); const compose = (...fns) => (x) => fns.reduceRight((value, fn) => fn(value), x); ``` Each takes any number of functions and returns a new function. Calling that returned function threads the initial value through the list, handing each function the previous one's result. ```js const trim = (s) => s.trim(); const lower = (s) => s.toLowerCase(); const hyphenate = (s) => s.replaceAll(' ', '-'); const slugify = pipe(trim, lower, hyphenate); slugify(' Hello There '); // "hello-there" const slugify2 = compose(hyphenate, lower, trim); slugify2(' Hello There '); // "hello-there" - same result, reversed argument list ``` ## The only difference is direction `pipe(f, g, h)(x)` evaluates `h(g(f(x)))`. `compose(f, g, h)(x)` evaluates `f(g(h(x)))`. Compose matches how function application is written on paper — the innermost call is on the right — while pipe matches the order you read the code and the order things actually happen. In application code `pipe` almost always wins on readability for exactly that reason; `compose` survives because it is the traditional spelling in functional libraries and because it reads naturally when you think of it as "f after g". A useful way to remember which is which: with `reduce` the list is consumed front to back, so the first function runs first — that is `pipe`. `reduceRight` consumes back to front, so the last function runs first — that is `compose`. ## The unary constraint The fold carries exactly one value between stages. That produces a hard rule: **every function after the first must be unary**, and its parameter type must match what the previous stage returns. If a stage needs two inputs, it cannot sit in the chain as-is — you either bake the extra input in ahead of time by writing a small factory that returns a unary function, or you thread an object through the pipeline and have each stage take and return that object. The first stage can be made variadic with a small tweak: ```js const pipe = (...fns) => (...args) => fns.slice(1).reduce((value, fn) => fn(value), fns[0](...args)); ``` Now `pipe(add, double)(2, 3)` works because `add` receives both arguments; everything after it is still unary. Whether that is worth the extra complexity is a judgment call — the plain version is easier to reason about, and most pipelines start from a single value anyway. ## The failure modes worth naming **A missing `return`.** A stage written as `(user) => { user.name = user.name.trim(); }` returns `undefined`, and every later stage receives `undefined`. The error surfaces at the *next* stage, not at the guilty one, which is why pipeline bugs feel harder to locate than ordinary ones. The habit that prevents it: make every stage a pure transformation that returns its result rather than a mutation that returns nothing. **An async stage.** If one function returns a promise, the next synchronous stage receives a promise object, not the resolved value — `pipe(fetchUser, formatName)` calls `formatName` with a pending promise. Mixing sync and async in a plain pipe is a silent type mismatch. An async pipeline needs its own combinator that awaits each stage. **A throwing stage.** Nothing in the fold catches, so an exception in any stage propagates out of the composed function and the remaining stages never run. That is usually the behaviour you want, but it means the composed function has the union of every stage's failure modes and no place to attach context. **Debuggability.** A composed function has no meaningful name and the stack trace shows the anonymous inner arrow, which makes a long chain harder to debug than the equivalent sequence of named statements. Inserting a `tap` stage — `const tap = (label) => (v) => { console.log(label, v); return v; }` — is the standard trick, and it is itself another small higher-order function. ## When to reach for it Composition pays off when you have several genuinely reusable single-purpose transformations applied in a fixed order, and especially when you want to build the pipeline once and apply it to many values. It does not pay off when the steps need branching, several inputs, or error handling between stages — a plain sequence of named `const` assignments is clearer and debuggable. Being able to say *when not to compose* is as valuable in the interview as writing the one-liner.

  • Why do teams usually prefer pipe over compose in application code?
    Because the argument order matches execution order, so the code reads the way the data flows: `pipe(parse, validate, save)` says exactly what happens first. `compose` reverses that to match mathematical notation, which is fine in a functional-library idiom but forces every reader to mentally flip the list. The behaviour is identical; only legibility differs.
  • How would you compose functions where one stage is asynchronous?
    A plain pipe cannot: the next synchronous stage would receive a promise instead of a value. You need a dedicated combinator that awaits each stage, for example one that reduces over the list starting from a resolved value and awaits before applying the next function. Mixing the two kinds of stage in one plain pipe is a silent type mismatch, not a style question.
  • How do you debug a long pipeline when the output is wrong?
    Insert a `tap` stage — a higher-order helper that logs its input and returns it unchanged — between the stages you suspect, which isolates where the value first goes wrong without changing behaviour. If the pipeline is long enough to need that regularly, that is a signal to split it into named intermediate values instead.

saying these in an interview costs you the question

  • Swaps the directions of pipe and compose
  • Thinks every stage receives the original arguments
  • Mixes an async stage into a synchronous pipe
  • Writes a stage that mutates and returns nothing
  • Claims composition is always more readable than named steps

context