_.partial()
intermediatePre-fill leading arguments of a function — partial application, and how it differs from currying.
Implement pipe and compose in JavaScript with reduce, extend to async pipelines, and handle errors honestly — the composition one-liner with real interview depth.
Implement
pipe(...fns): return a function that feeds its input through everyfnleft 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.
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 = 11Also 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 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.
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.
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.
pipe() → identity (above).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.pipe(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..catch(console.error) mid-pipeline (the NaN factory above)..then branching without acknowledging the Zalgo contract.compose when asked for pipe (direction flip) — verbalize the order before coding.pipeline(a, b).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.(value, next) and choose to call next — inversion of control; sketching the signature difference is enough.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.Pre-fill leading arguments of a function — partial application, and how it differs from currying.
The harder follow-up: support _ placeholders so arguments can arrive in any order.
Guarantee a function runs exactly once and returns its cached result forever after.
Transform f(a, b, c) into f(a)(b)(c): the classic closure exercise, with arity handling.