intermediateCommon5 min read · Updated Jul 18, 2026

Implement pipe() and compose()

Implement pipe and compose in JavaScript with reduce, extend to async pipelines, and handle errors honestly — the composition one-liner with real interview depth.


The problem

Implement pipe(...fns): return a function that feeds its input through every fn left to right — pipe(f, g, h)(x) === h(g(f(x))).

A one-liner with unusual depth-per-character: it's reduce applied to functions as data, it has a mirror twin (compose), an async upgrade with a real design decision inside, and an error-handling extension where most published solutions are simply wrong.

Implementation

const pipe = (...fns) => (input) => fns.reduce((acc, fn) => fn(acc), input);
 
const compose = (...fns) => (input) => fns.reduceRight((acc, fn) => fn(acc), input);

The reduce accumulator is the value flowing through the pipeline; each step applies one function to it. compose is the same fold from the right — compose(h, g, f)(x) === h(g(f(x))), matching math notation (h∘g∘f), while pipe matches reading order. Knowing both names and their directions is table stakes; knowing they're the same fold with direction flipped is understanding.

const double = (x) => x * 2;
const add3   = (x) => x + 3;
const half   = (x) => x / 2;
 
pipe(double, add3, half)(5);    // half(add3(double(5))) = (5*2+3)/2 = 6.5
compose(double, add3, half)(5); // double(add3(half(5))) = (5/2+3)*2 = 11

Also worth 10 seconds: an empty pipe() returns the identity function (reduce over [] yields the initial value untouched) — the correct algebraic behavior, for free, with no special case. When zero-input cases "just work," it usually means the shape is right.

The async upgrade — and the decision inside it

The moment one stage returns a promise, sync reduce passes a promise object into the next function. The clean async pipeline awaits every stage:

const pipeAsync = (...fns) => async (input) => {
  let acc = input;
  for (const fn of fns) {
    acc = await fn(acc); // await handles both sync and async stages
  }
  return acc;
};
 
const result = await pipeAsync(
  fetchUser,          // async
  (user) => user.id,  // sync — await on a non-promise is a no-op wrap
  fetchOrders,        // async
)(userId);

The decision to narrate: duck-typed branching (acc.then ? ... : ...) vs awaiting everything. Branching keeps sync pipelines synchronous but makes the composed function's return type depend on runtime values — sometimes a value, sometimes a promise. That's the sometimes-sync-sometimes-async ("Zalgo") contract that promise semantics deliberately forbid. await-everything costs one microtask per stage and buys an honest, uniform contract: pipeAsync always returns a promise. Choose the uniform contract and say why — that reasoning is the senior content of this question.

Error handling: what most solutions get wrong

The widely circulated "add error handling" version does .catch(console.error) per stage. Trace it: the catch swallows the rejection and returns undefined, so the pipeline continues — the next stage receives undefined, and pipe(double, failingStage, add3)(6) cheerfully prints NaN while claiming the pipeline "stopped." It converts a loud failure into corrupt data downstream — strictly worse than doing nothing.

The honest options:

// 1. Default: let it throw/reject. The CALLER owns error policy.
try {
  await pipeAsync(a, b, c)(x);
} catch (err) { /* one place, full context */ }
 
// 2. Railway style: make failure a VALUE that skips remaining stages.
const pipeSafe = (...fns) => async (input) => {
  let acc = input;
  for (const fn of fns) {
    try {
      acc = await fn(acc);
    } catch (error) {
      return { ok: false, error, failedAt: fn.name || "anonymous" };
    }
  }
  return { ok: true, value: acc };
};

Option 2 (Result objects — the "railway-oriented" pattern, same shape as allSettled's status objects) is legitimate — but it changes the return contract and must be named as such. The unforgivable version is the one that catches, logs, and keeps piping.

Where you see it in production

Redux middleware (applyMiddleware literally ships a compose), Express/Koa middleware chains (pipe with early-exit), RxJS's pipe(map, filter, ...), data-transform layers (parse → validate → normalize → shape), and every functional utility belt. The interview-adjacent insight: the iterator helpers now give arrays-and-iterators a built-in lazy pipeline — map/filter/take on iterators — so "when would you pipe vs chain?" has a modern answer.

Edge cases interviewers probe

  • Empty pipe() → identity (above).
  • Single function → behaves as that function; no wrapper weirdness.
  • Multi-arg first stage — the pipeline is unary after stage one by construction; support pipe(...fns)(...args) by spreading only into the first call (fns[0](...args) then fold the rest) if asked. Currying is how you make multi-arg functions pipeline-friendly — the two questions are adjacent on purpose.
  • Non-function inputpipe(f, undefined, g) should fail fast at composition time (fns.every(f => typeof f === "function") or throw on first use) rather than mid-pipeline with a confusing undefined is not a function three stages in.
  • this — point-free pipelines are this-free by design; piping methods requires binding first, worth one sentence.

Common mistakes

  • .catch(console.error) mid-pipeline (the NaN factory above).
  • Duck-typed .then branching without acknowledging the Zalgo contract.
  • Writing compose when asked for pipe (direction flip) — verbalize the order before coding.
  • Only handling exactly-unary pipelines when the interviewer's test calls pipeline(a, b).
  • Presenting pipe as the way to write JS — a three-stage pipe of named functions is readable; a fifteen-stage point-free tower is a review comment. Taste is part of the signal.

Follow-up questions

  • "Implement compose from your pipe."compose(...fns) = pipe(...fns.reverse()) (on a copy!) or swap to reduceRight; both answers accepted, mutation of the input array is not.
  • "Lazy pipelines over large collections?" — per-element single pass via generators/iterator helpers instead of array-materializing stages; the map-fusion idea in one sentence.
  • "Add early exit (Koa-style middleware)?" — stages get (value, next) and choose to call next — inversion of control; sketching the signature difference is enough.
  • "Type pipe in TypeScript?" — pairwise-inferred overloads up to N stages (how RxJS ships it); fully variadic inference is at the edge of the type system — knowing that boundary is the answer.
  • "Pipe with multiple return values between stages?" — tuples/objects as the flowing value; conventionally the pipeline value should be one coherent thing — if stages need three loose values, the design wants a named shape.

  • _.partial()

    intermediate

    Pre-fill leading arguments of a function — partial application, and how it differs from currying.

  • _.once()

    beginner

    Guarantee a function runs exactly once and returns its cached result forever after.

  • Currying

    intermediate

    Transform f(a, b, c) into f(a)(b)(c): the classic closure exercise, with arity handling.