Composition & pipelines
Chain small JavaScript functions with compose and pipe, build them with reduce, debug pipelines, handle async flows, and understand the pipeline proposal.
- 01Build function pipelinesImplement
composeandpipefrom scratch withreduceRightandreduce. - 02Choose composable shapesExplain why data-last helpers enable point-free pipelines in libraries like lodash/fp and Ramda.
- 03Debug and modernize safelyUse
tap, writepipeAsync, and describe the current pipeline operator proposal honestly.
Chain small functions into bigger ones
Functional JavaScript gets easier when each function does one small job: trim a string, validate a record, calculate a total, format a message. Composition is the move that connects those jobs without turning the code into nested parentheses.
You already saw the pieces in higher-order functions, pure functions, and currying. This lesson puts them together: build compose and pipe, choose data-last helper shapes, debug with tap, compose async functions, and read the pipeline operator proposal without pretending it is shipped JavaScript.
Function composition means feeding one function's return value into the next function. A pipeline is the same idea written as a visible sequence of stages, usually left to right.
| Form | Direction | How to read it |
|---|---|---|
compose(a, b, c)(x) | Right to left | Reads like nested calls: a(b(c(x))). |
pipe(a, b, c)(x) | Left to right | Reads like a data flow: x then a, then b, then c. |
| Method chaining | Left to right | Works only when the current value owns the next method. |
| Pipeline operator proposal | Left to right | Proposed syntax for expressions, but not part of JavaScript yet. |
The rule for the whole lesson is simple: each stage should accept one value and return the next value. When a stage has a side effect, throws, mutates, or returns the wrong thing, the pipeline becomes harder to trust.
A pipeline mental model
ANALOGYPicture a clean assembly line. A raw part enters, one station drills, the next polishes, the next labels, and the finished part exits. Composition is the same idea for values: one stage transforms the value and passes it on.
- In real life: A part moves to the next station
- In JavaScript: A value becomes the next function's input
- In real life: Each station does one operation
- In JavaScript: Each function is small and named
- In real life: Inspectors can check a part without changing it
- In JavaScript: A
tapstage logs and returns the same value - In real life: A station must pass the part along
- In JavaScript: Every stage must return the next value
Where the analogy stops: Real assembly lines move physical parts in one direction. JavaScript functions can branch, throw, mutate shared state, or do async work, so you still need clear names and tests.
The analogy is useful because it makes two debugging questions obvious: which station received the wrong input, and which station failed to pass the correct output forward? The rest of the lesson answers those questions in code.
compose and pipe: same chain, opposite reading direction
STEP THROUGHcompose comes from mathematics. compose(square, increment, double)(3) means square(increment(double(3))). That is why it runs from the rightmost function back toward the left. It is compact once you know the convention, but beginners often read it in the wrong direction.
Step through compose(square, increment, double). The code is built with reduceRight, so execution starts at the right edge and travels left.
script
const increment = (value) => value + 1;const square = (value) => value * value; function compose(...fns) { return (input) => { return fns.reduceRight((value, fn) => fn(value), input); };} const transform = compose(square, increment, double);console.log(transform(3));pipe flips the reading direction. The data enters at the left, then flows through the functions in the same order they are listed. Many teams prefer pipe for UI and data-cleaning code because the source reads like a recipe.
Step through pipe(trim, lower, dash). This time reduce runs left to right, so each output becomes the next input.
script
const lower = (text) => text.toLowerCase();const dash = (text) => text.replaceAll(" ", "-"); function pipe(...fns) { return (input) => { return fns.reduce((value, fn) => fn(value), input); };} const toSlug = pipe(trim, lower, dash);console.log(toSlug(" Composition Lesson "));Both helpers are tiny. The real cost is human: switching between right-to-left and left-to-right composition makes reviews slower. Pick the helper your team can trace quickly, name it consistently, and keep the stages small.
Composing with reduce and reduceRight
The implementation is just a reducer. The accumulator is not a number this time; it is the current value moving through the chain. On each iteration, the reducer calls one function and returns that function's result as the next accumulator.
pipeusesreduce. It starts at the first function in the array and walks forward.composeusesreduceRight. It starts at the last function in the array and walks backward.- The initial accumulator is the input. Without that starting value, the first function would not receive the original data.
Read fns.reduce((value, fn) => fn(value), input) as: “start with input; for each fn, replace value with fn(value).” That is the whole loop hidden inside pipe.
The step-throughs above use the lesson's real functions and recordings. Back up and replay them until the order feels mechanical: compose starts on the right, pipe starts on the left.
Data-last functions make pipelines easier
INTERACTIVEA pipeline stage is easiest to compose when it is a unary function: one input, one output. But useful helpers often need configuration too. A filter needs a predicate. A join needs a separator. A formatter needs a locale. Data-last helpers receive those options first and return a unary function that waits for the data.
| Shape | Example | Composition effect |
|---|---|---|
| Data-first | filter(items, predicate) | Natural for one immediate call, but the data argument blocks direct point-free composition. |
| Data-last | filter(predicate)(items) | Configure the helper first; the returned unary function fits into pipe. |
| lodash/fp and Ramda | Helpers are curried and data-last | They can write pipe(filter(isOpen), map(format)) without naming items. |
That is why functional helper libraries such as lodash/fp and Ramda lean on curried, data-last helpers. You configure a transformation now, then pipe supplies the collection later. The final code can be point-free because you do not need to write (items) => at every stage.
const lessons = [ { title: "Currying", published: true }, { title: "Composition", published: true }, { title: "Memoization", published: false },];const includeDrafts = false; const filter = (predicate) => (items) => items.filter(predicate);const map = (fn) => (items) => items.map(fn);const join = (separator) => (items) => items.join(separator);const pipe = (...fns) => (input) => fns.reduce((value, fn) => fn(value), input); const visibleTitles = pipe( includeDrafts ? (items) => items : filter((lesson) => lesson.published), map((lesson) => lesson.title), join(" -> "),); console.log(visibleTitles(lessons));Currying -> Composition
The first stage is filter((lesson) => lesson.published), so the draft is removed before titles are mapped.
lessons appears only at the final call.Data-last is not morally better than data-first. It is a trade-off. Data-first calls are direct when you already have the data in hand. Data-last calls are convenient when you are building reusable stages before the data arrives.
Debugging pipelines with tap
TRACEPipelines are readable when everything works, but they can hide intermediate values when something breaks. A tap helper fixes that. It observes the current value, logs or records it, and returns the same value unchanged.
const pipe = (...fns) => (input) => fns.reduce((value, fn) => fn(value), input); const tap = (label) => (value) => { const shown = Array.isArray(value) ? value.join(",") : value; console.log(label + ":", shown); return value;}; const normalize = (text) => text.trim().toLowerCase();const words = (text) => text.split(/\s+/);const dropShort = (items) => items.filter((word) => word.length > 3); const keywords = pipe( normalize, tap("normalized"), words, tap("words"), dropShort, tap("filtered"),); console.log(keywords(" Compose small functions well ").join("|"));normalized: compose small functions wellwords: compose,small,functions,wellfiltered: compose,small,functions,wellcompose|small|functions|well
A tap stage logs the value and returns it unchanged. If it returned console.log(...), the next stage would receive undefined.
The return value is the important part. console.log(value) returns undefined. If you put that directly in a pipeline, the next stage receives undefined. A proper tap returns value after logging.
Keep tap small. In production code, prefer a logger you can disable or a named debug stage that is easy to search. A pipeline full of permanent logs is still hard to read.
Composing async functions with pipeAsync
ASYNCReal front-end flows often wait: fetch a cart, add prices, check permissions, format a receipt. If each stage may return a promise, a normal pipe will pass promises around as plain values. pipeAsync uses a promise accumulator so every stage waits for the one before it.
const pipeAsync = (...fns) => (input) => fns.reduce( (promise, fn) => promise.then(fn), Promise.resolve(input), ); const getCart = async (userId) => ({ userId, items: ["notebook", "pen"] });const addPrices = async (cart) => ({ ...cart, total: 12 });const formatReceipt = async (cart) => `User ${cart.userId}: $${cart.total}`; async function main() { const receipt = await pipeAsync(getCart, addPrices, formatReceipt)(42); console.log(receipt);} main();waiting for promise...
Each stage may return a value or a promise. The reducer keeps a promise accumulator so the next stage waits for the previous result.
This is still sequential. Use it when stage two genuinely needs the result from stage one. If independent requests can run together, start them together with Promise.all before or inside a stage, rather than making a waterfall by accident.
The pipeline operator proposal
PROPOSALYou may see |> in blog posts. It is a TC39 proposal for writing pipelines as syntax instead of helper functions. I verified the current public proposal status with a web search and the TC39 proposal repository on September 26, 2026: the Hack-style pipeline proposal is still Stage 2. It is not part of the JavaScript standard, and it does not run in Node 22 or current browsers without a transform.
The official proposal README lists Stage 2 and says its examples use % as a placeholder topic token that is “almost certainly not” final. Treat the syntax below as a sketch. Use the runnable pipe version for real code.
Source: tc39/proposal-pipeline-operator.
pipe equivalentconst pipe = (...fns) => (input) => fns.reduce((value, fn) => fn(value), input); const slug = pipe( (text) => text.trim(), (text) => text.toLowerCase(), (text) => text.replaceAll(" ", "-"),)(" Hello Pipeline "); console.log(slug);const slug = " Hello Pipeline " |> %.trim() |> %.toLowerCase() |> %.replaceAll(" ", "-"); console.log(slug);hello-pipeline
The left block is the code to run today. The right block is a Stage 2 proposal sketch, so the lesson marks it as not runnable.
% as a placeholder token in examples and warns that the final token choice is not settled.The proposal is worth understanding because it explains what problem the language is trying to solve: readable left-to-right expressions. But until browsers ship it, examples must either be marked non-runnable or shown next to a standard JavaScript equivalent.
Where pipelines help in real applications
SORT ITComposition works best when the stages are named after business ideas: normalize a slug, select published lessons, price a cart, validate a form model, prepare request headers. It works poorly when every stage is an anonymous trick that must be decoded line by line.
- Formatting: trim, normalize, slugify, and format for display.
- Validation: parse, check, enrich, and return a result object.
- Requests: build a URL, attach headers, fetch, parse JSON, and map errors.
- State updates: apply pure transformations before setting UI state.
const trim = (text) => text.trim();const visible = filter((item) => item.published);tap("after map")(value) => console.log(value)async (cart) => ({ ...cart, total: 12 })(items) => items.push("new")
Sort each snippet by whether it transforms the value, only observes it, or breaks the flow.
Common misconceptions
- “
composeandpipeare interchangeable words.” They connect functions, but they normally run in opposite directions. - “Point-free code is always cleaner.” It is clean when the stage names tell the story. It is cryptic when a temporary variable would explain the data.
- “A debug log can be a pipeline stage.” Only if the stage returns the original value. Plain
console.logreturnsundefined. - “Async stages work with a normal pipe.” A normal pipe passes promises as values. Use
pipeAsyncor write theawaitsequence directly. - “The pipeline operator is ready because Babel can parse it.” Tooling experiments are not the same as JavaScript engines shipping a standard feature.
| Confusion | Reality |
|---|---|
compose vs pipe | Same goal, opposite reading direction. Pick one convention per codebase. |
| Point-free | Not automatically better. Keep names when they explain the data or avoid arity bugs. |
tap | Useful only when it returns the original value. A logging function that returns undefined breaks the flow. |
| Pipeline operator | Syntax proposal, not a runtime feature today. Use real functions until engines ship it. |
Practice exercises
5 EXERCISESRead the code and type the number printed by the final line.
const double = (value) => value * 2;
const increment = (value) => value + 1;
const square = (value) => value * value;
const compose = (...fns) => (input) =>
fns.reduceRight((value, fn) => fn(value), input);
const transform = compose(square, increment, double);
console.log(transform(3));The order is double(3) → 6, increment(6) → 7, and square(7) → 49.
Predict the exact text printed by the data-last helper pipeline.
const lessons = [
{ title: "Currying", published: true },
{ title: "Composition", published: true },
{ title: "Memoization", published: false },
];
const filter = (predicate) => (items) => items.filter(predicate);
const map = (fn) => (items) => items.map(fn);
const join = (separator) => (items) => items.join(separator);
const pipe = (...fns) => (input) => fns.reduce((value, fn) => fn(value), input);
const visibleTitles = pipe(
filter((lesson) => lesson.published),
map((lesson) => lesson.title),
join(" -> "),
);
console.log(visibleTitles(lessons));The unpublished Memoization item is filtered out. The two remaining titles are joined as Currying -> Composition.
The starter prints NaN. Fix the composition order so the printed receipt is $11.00.
const compose = (...fns) => (input) =>
fns.reduceRight((value, fn) => fn(value), input);
const addTax = (price) => price * 1.1;
const formatMoney = (price) => "$" + price.toFixed(2);
const total = compose(addTax, formatMoney);
console.log(total(10));const total = compose(formatMoney, addTax);addTax must receive the number first. formatMoney runs last and turns the taxed number into $11.00.
Type the text the promise chain eventually logs.
const pipeAsync = (...fns) => (input) =>
fns.reduce((promise, fn) => promise.then(fn), Promise.resolve(input));
const getCart = async (userId) => ({ userId, items: ["notebook", "pen"] });
const addPrices = async (cart) => ({ ...cart, total: 12 });
const formatReceipt = async (cart) => `User ${cart.userId}: $${cart.total}`;
pipeAsync(getCart, addPrices, formatReceipt)(42).then(console.log);The fulfilled cart becomes { userId: 42, total: 12 }, so the final formatter prints User 42: $12.
A checkout pipeline sometimes produces the wrong total. Which helper lets you insert temporary logs between stages without breaking the flow?
const tap = (label) => (value) => { console.log(label, value); return value; };A tap or trace helper is the right tool because it returns the original value for the next stage.
Check your understanding
8 QUESTIONSTrace one value at a time. For output questions, write the stage order before choosing an answer. For proposal questions, separate runtime JavaScript from syntax that still needs a transform.
Question 1 of 8Which sentence best describes function composition?
Choose an answer to see the explanation.
Question 2 of 8What does this composed call print?
Read the code, then predictconst double = (value) => value * 2; const increment = (value) => value + 1; const square = (value) => value * value; const compose = (...fns) => (input) => fns.reduceRight((value, fn) => fn(value), input); console.log(compose(square, increment, double)(3));Choose an answer to see the explanation.
Question 3 of 8What does this pipe print?
Read the code, then predictconst pipe = (...fns) => (input) => fns.reduce((value, fn) => fn(value), input); const slug = pipe( (text) => text.trim(), (text) => text.toLowerCase(), (text) => text.replaceAll(" ", "-"), )(" Composition Lesson "); console.log(slug);Choose an answer to see the explanation.
Question 4 of 8Why do lodash/fp and Ramda prefer data-last helpers?
Choose an answer to see the explanation.
Question 5 of 8Which reducer direction matches
compose(a, b, c)(x)?Choose an answer to see the explanation.
Question 6 of 8What must a
taphelper return?Read the code, then predictconst tap = (label) => (value) => { console.log(label + ":", value); return value; }; console.log(tap("seen")(7) * 2);Choose an answer to see the explanation.
Question 7 of 8What does this async pipeline eventually print?
Read the code, then predictconst pipeAsync = (...fns) => (input) => fns.reduce((promise, fn) => promise.then(fn), Promise.resolve(input)); const getName = async (id) => ({ id, name: "Ada" }); const format = async (user) => "Hello " + user.name; pipeAsync(getName, format)(1).then(console.log);Choose an answer to see the explanation.
Question 8 of 8What is the honest status of the Hack-style pipeline operator today?
Choose an answer to see the explanation.
Key takeaways
composechains functions right to left withreduceRight.pipechains functions left to right withreduce.- Data-last helpers return unary stages, which makes point-free pipelines possible.
taplogs or traces while returning the original value unchanged.pipeAsyncuses a promise accumulator for dependent async stages.- The Hack-style pipeline operator is Stage 2 as of September 26, 2026, and not runnable JavaScript today.
Remember the one-liner.
Composition is a value moving through small functions; keep the order visible, the stages pure when possible, and proposal syntax out of runnable examples.
Up next: Memoization & laziness.