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.lengthlies 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);
// 73 → double → 6 → increment → 7pipe(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);
// 83 → increment → 4 → double → 8compose(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 isfn.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);
// 6remember [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)();
// 6sum(1) → total 1
(2) → total 3
(3) → total 6
() → 6Solution
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.uncurryflattens(a) => (b) => ….curryRightapplies 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
| Utility | Time | Extra space |
|---|---|---|
pipe / compose | O(k) fns | O(1) / O(k) copy |
| curry / placeholders | O(n) args | O(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 sequentialDirection first. Then what the closure stores.