Topics
v6.0.0
On this page · sections

JavaScript Pipe, Compose & Curry

Can you send a value through functions — left or right — then collect arguments until the arity is full?

Level: Medium · Coding Round · Function Flow

Why This Question Matters

  • Pipelines show up in selectors, validators, and middleware.
  • Interviewers test direction, arity, and closures, not fancy FP vocabulary.
  • reverse() mutates. fn.length lies with defaults and rest.
  • Placeholders and infinite sum() are the follow-ups after basic curry.

Five problems. Same idea: what does the next call remember?

Problem 1 — pipe

“Implement pipe(...fns). Left to right. First fn gets the value. Empty pipe is identity.”

function pipe(...fns) {}

Example

const double = (x) => x * 2; const increment = (x) => x + 1; pipe(double, increment)(3); // 7
3 → double → 6 → increment → 7

pipe(f, g, h)(x) is h(g(f(x))).

Solution

function pipe(...fns) { if (!fns.length) { return (x) => x; } return (x) => fns.reduce((value, fn) => fn(value), x); }

Async follow-up: do not Promise.all. Each step needs the previous result:

function pipeAsync(...fns) { return async (x) => { let value = x; for (const fn of fns) { value = await fn(value); } return value; }; }

Use case

Normalize → validate → format before UI.

Problem 2 — compose

“Implement compose(...fns). Right to left. Do not mutate the function list.”

function compose(...fns) {}

Example

compose(double, increment)(3); // 8
3 → increment → 4 → double → 8

compose(f, g)(x) is f(g(x)). pipe(f, g)(x) is g(f(x)).

Solution

function compose(...fns) { return pipe(...[...fns].reverse()); }

fns.reverse() mutates. Copy first.

Use case

Math-style composition. Same pipeline, opposite reading order.

Follow-ups

Q. Which direction in an app?

Answer: pipe reads like the data flow. compose matches textbooks. Pick one and document it.

Problem 3 — curry

“Implement curry(fn, arity). One or many args per call. Run when enough args are collected. Default arity is fn.length.”

function curry(fn, arity) {}

Example

const add3 = (a, b, c) => a + b + c; const curried = curry(add3); curried(1)(2)(3); curried(1, 2)(3); curried(1)(2, 3); // 6
remember [1] → remember [1, 2] → fn(1, 2, 3)

Curry collects until arity. Partial presets some args and returns one function for the rest:

function partial(fn, ...preset) { return (...args) => fn(...preset, ...args); }

Solution

function curry(fn, arity = fn.length) { const collect = (...args) => { if (args.length >= arity) { return fn(...args.slice(0, arity)); } return (...next) => collect(...args, ...next); }; return collect; }

fn.length stops before the first default or rest param. Prefer an explicit arity when the signature is messy.

Use case

const loadUser = curry(fetchBy)(api)(token) then pass id later.

Follow-ups

Q. Trust fn.length?

Answer: No. function fn(a, b = 10, c) {} has length 1.

Problem 4 — Infinite sum

“Implement sum(1)(2)(3)(). No fixed arity. Empty call returns the total.”

function sum(total) {}

Do not treat undefined as “done”. Use arguments.length === 0.

Example

sum(1)(2)(3)(); // 6
sum(1) → total 1 (2) → total 3 (3) → total 6 () → 6

Solution

function sum(total = 0) { function next(n) { if (arguments.length === 0) { return total; } return sum(total + n); } return next; }

Optional: valueOf / Symbol.toPrimitive so +sum(1)(2)(3) works. Not required for the core contract.

Use case

Classic closure round. Same pattern as collecting until a terminator.

Problem 5 — Placeholders, uncurry, curryRight

“Lodash-style _ fills holes. uncurry flattens (a) => (b) => …. curryRight applies collected args from the right.”

const _ = Symbol("_"); function curryPlaceholder(fn, arity) {} function uncurry(fn, arity) {} function curryRight(fn, arity) {}

Do not use undefined as _. It is a legal argument.

Example

const join3 = (a, b, c) => [a, b, c].join("-"); curryPlaceholder(join3)(_, "b")("a", "c"); // "a-b-c" uncurry((a) => (b) => (c) => a + b + c, 3)(1, 2, 3); // 6 curryRight(join3)("c")("b")("a"); // "a-b-c"

Solution

const _ = Symbol("_"); function curryPlaceholder(fn, arity = fn.length) { function curried(...args) { const ready = args.length >= arity && args.slice(0, arity).every((arg) => arg !== _); if (ready) { return fn(...args.slice(0, arity)); } return (...next) => { const merged = []; let i = 0; for (const arg of args) { if (arg === _ && i < next.length) { merged.push(next[i++]); } else { merged.push(arg); } } while (i < next.length) { merged.push(next[i++]); } return curried(...merged); }; } return curried; } function uncurry(fn, arity) { return (...args) => { let result = fn; for (let i = 0; i < arity; i++) { result = result(args[i]); } return result; }; } function curryRight(fn, arity = fn.length) { const collect = (...args) => args.length >= arity ? fn(...args.slice(0, arity).reverse()) : (...next) => collect(...args, ...next); return collect; }

curryRight still collects in call order, then reverses once at invoke.

Complexity

UtilityTimeExtra space
pipe / composeO(k) fnsO(1) / O(k) copy
curry / placeholdersO(n) argsO(n)

Senior Engineer Interview Answer

“Pipe is left to right; compose is the reverse and I copy before reverse(). Curry closes over collected args until arity; partial just presets. Infinite sum terminates on an empty call, not undefined. Placeholders need a Symbol. I would not trust fn.length when the function has defaults or rest.”

Interview Checklist

✓ pipe L→R ✓ compose R→L ✓ copy before reverse ✓ curry vs partial ✓ empty sum() ✓ Symbol placeholder ✓ explicit arity ✓ async pipe is sequential

Direction first. Then what the closure stores.

Last updated on