ESC

Type to search the knowledge base.

Compose and Pipe

Right-to-left compose vs left-to-right pipe — building unary pipelines, debugging intermediate values, and when a plain function is enough.

intermediate3 min read
  • javascript
  • compose
  • pipe
  • functional

compose and pipe chain unary functions so data flows through small steps. Libraries disagree on direction. Interviews care that you pick a direction and stick to it — and that you know compose is usually right-to-left (math style).

Definitions

// compose: f∘g∘h (x) = f(g(h(x)))  — right to left
const compose =
  (...fns) =>
  (x) =>
    fns.reduceRight((v, f) => f(v), x);

// pipe: left to right — often easier to read top-to-bottom
const pipe =
  (...fns) =>
  (x) =>
    fns.reduce((v, f) => f(v), x);

const trim = (s) => s.trim();
const lower = (s) => s.toLowerCase();
const exclaim = (s) => `${s}!`;

compose(exclaim, lower, trim)('  Hi  '); // "hi!"
pipe(trim, lower, exclaim)('  Hi  ');    // "hi!"

Same functions, opposite evaluation order. Prefer pipe in application code if your team reads top-down.

Why bother

// nested
exclaim(lower(trim(input)));

// pipeline
pipe(trim, lower, exclaim)(input);

Pipelines keep intermediate transforms named and reorderable. They shine when steps are pure and unary.

Multi-arg at the edges

Only the first function in a pipe can reasonably take multiple args if you adapt:

const pipeArgs =
  (f, ...fns) =>
  (...args) =>
    fns.reduce((v, fn) => fn(v), f(...args));

const area = (w, h) => w * h;
const double = (n) => n * 2;

pipeArgs(area, double)(3, 4); // 24

Or curry the first step. Inside the pipeline, keep functions unary.

Async pipe

const pipeAsync =
  (...fns) =>
  (x) =>
    fns.reduce(async (v, f) => f(await v), x);

const fetchUser = async (id) =>
  (await fetch(`/api/users/${id}`)).json();
const pickName = (u) => u.name;

await pipeAsync(fetchUser, pickName)(7);

Errors reject the returned promise — wrap with try/catch at the call site.

Debugging

const tap =
  (label) =>
  (v) => {
    console.log(label, v);
    return v;
  };

pipe(trim, tap('after trim'), lower, exclaim)('  OK  ');

tap is a pure-looking spy that returns its input.

When not to use them

// clearer as a normal function
function normalizeEmail(raw) {
  const trimmed = raw.trim().toLowerCase();
  if (!trimmed.includes('@')) throw new Error('invalid');
  return trimmed;
}

Heavy branching, early returns, and multi-value intermediates fight unary pipelines. Don’t invent compose for two calls.

Point-free style (use lightly)

const scores = users.map(pipe(getScore, clamp0to100, Math.round));

Readable when each name is obvious. Opaque when someone has to jump five files to see the order.

Interview answer

“compose applies right-to-left, pipe left-to-right. Both reduce a list of unary functions. I use pipe for readable data transforms, keep steps pure, and fall back to a named function when control flow gets non-linear. Async variants await each step.”

Typed mental model and arity

Compose/pipe assume each step returns what the next accepts. That’s the real design work — not the 4-line reduce helper.

// explicit types in comments / TS make pipelines maintainable
// string → string → number → string
const summarize = pipe(trim, lower, (s) => s.length, String);

// branching: leave the pipeline
function normalize(input) {
  const base = pipe(trim, lower)(input);
  if (!base) return null;
  return exclaim(base);
}

Library variants (lodash/fp, Ramda) auto-curry and reverse argument order for data-last style. If your codebase isn’t already FP-heavy, a local 5-line pipe beats importing a 50-function toolkit for two transforms.

Further reading

Related guides