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 pipelinesWrite
composeandpipeyourself, usingreduceRightandreduce. - 02Shape helpers so they fitExplain why helpers that take the data last, like those in lodash/fp and Ramda, fit straight into a pipeline.
- 03Find bugs and know what's newLook inside a pipeline with
tap, writepipeAsyncfor functions that wait, and say where the|>proposal stands today.
Chain small functions into bigger ones
Code is easier to read when each function does one small job. One function trims a string. Another checks a form. Another adds up a total. Composition is a way to join small functions like these into one bigger function, without writing calls inside calls inside calls.
This lesson uses three ideas from earlier lessons: higher-order functions (functions that take or return functions), pure functions, and currying. In this lesson you will:
- build two small helpers,
composeandpipe; - shape your own helpers so they fit together;
- look at the values in the middle of a chain with
tap; - chain functions that have to wait, like network requests;
- read the planned
|>syntax, which JavaScript does not have yet.
Function composition means giving one function's return value to the next function as its input. A pipeline is the same idea written as a list of steps, usually read from left to right. Each step in the list is called a stage.
const trim = (text) => text.trim();
const titleCase = (text) => text[0].toUpperCase() + text.slice(1);
const pipe = (...fns) => (input) =>
fns.reduce((value, fn) => fn(value), input);
const formatTea = pipe(trim, titleCase);
console.log(formatTea(" tea "));Both functions here are unary. That means they take one input and return one output. In plain words, line by line:
- Line 1:
trimremoves the spaces at the start and end of a string. - Line 2:
titleCaseturns the first letter into a capital letter. - Lines 3–4:
pipejoins functions together. You'll build it step by step below. - Line 5:
formatTearunstrimfirst, thentitleCase. - Line 7: prints
Tea, becausetitleCasegets the text after it was trimmed.
trim returns is what titleCase gets.There are four ways to write a chain of steps. Read each row of the table as: "when I write this, the steps run in this direction."
| When you write | Which way it runs | How to read it |
|---|---|---|
compose(a, b, c)(x) | Right to left. | It means the same as the calls inside calls a(b(c(x))). |
pipe(a, b, c)(x) | Left to right. | x goes into a, then b, then c, in the order you wrote them. |
A chain of methods, like text.trim().toLowerCase() | Left to right. | It works only when each result has the next method on it. |
The planned |> operator | Left to right. | It is a plan for new syntax. JavaScript does not have it yet. |
One rule holds for the whole lesson: each stage takes one value and returns the next value. A stage can break this rule. It might change data outside itself, throw an error, or return the wrong thing. Then the pipeline is harder to trust.
Picture a pipeline as a recipe
ANALOGYA cook follows a short recipe. First the cook washes the vegetables. Then the cook cuts them. Then the finished dish goes to the table. Each step works on what the step before it made.
- In real life: The cook washes the vegetables first
- In JavaScript: The first function cleans up the value
- In real life: The next step cuts the washed vegetables
- In JavaScript: The next function gets that result
- In real life: The finished dish goes to the table
- In JavaScript: The last function returns the output
Thinking in steps helps when something goes wrong. You can ask two questions:
- Which stage got the wrong input?
- Which stage gave the wrong output to the next stage?
The rest of the lesson shows how to answer both questions in code.
compose and pipe: the same chain, read in opposite directions
STEP THROUGHcompose is a helper that joins functions. The name comes from math. compose(square, increment, double)(3) means the same as square(increment(double(3))). The innermost call, double(3), runs first. So compose runs the functions from right to left. Many beginners read it in the wrong direction at first.
compose, the value starts on the right and moves left. So double runs first and square runs last.const trim = (text) => text.trim();
const titleCase = (text) => text[0].toUpperCase() + text.slice(1);
const pipe = (...fns) => (input) =>
fns.reduce((value, fn) => fn(value), input);
const formatTea = pipe(trim, titleCase);
console.log(formatTea(" tea "));This example works because trim and titleCase have the same shape: one input, one output. A function that needs two different inputs can't go straight into this pipeline. First you must do one of two things:
- fill in one input ahead of time, with currying;
- or wrap the call in a new function that takes just one input.
Step through compose(square, increment, double). It uses reduceRight, so the functions run from right to 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 runs the other way. The value starts on the left. It goes through the functions in the order you wrote them. Many teams prefer pipe for page code and data cleanup, because it reads like a recipe, from first step to last.
Step through pipe(trim, lower, dash). This time reduce runs left to right, and each function's output is the next function's 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 for the people who read the code. Switching between right-to-left and left-to-right makes code slower to review. Pick one helper, use the same name for it everywhere, and keep the stages small.
Building them with reduce and reduceRight
Inside, each helper is one call to reduce. reduce walks through an array and keeps a running result. That running result is called the accumulator. Here the accumulator is not a sum. It is the value moving through the chain. On each turn, reduce calls one function with the value and keeps what it returns.
pipeusesreduce. It starts at the first function in the array and moves forward.composeusesreduceRight. It starts at the last function in the array and moves backward.- The starting accumulator is the input. Without it, the first function would not get 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 inside pipe.
The two step-throughs above run the lesson's real code. Go back and replay them until the order feels natural: compose starts on the right, and pipe starts on the left.
const pipe = (...fns) => (input) =>
fns.reduce((value, fn) => fn(value), input);
console.log(pipe()(7));
console.log(pipe((value) => value + 1)(7));Line 4 has no stages, so pipe()(7) gives back the input, 7, unchanged. Line 5 has one stage that adds 1, so the same input becomes 8. This is why reduce needs the input as its starting accumulator.
Put the data last so helpers fit
INTERACTIVEA stage fits a pipeline most easily when it is unary: one input, one output. But many useful helpers also need a setting:
- a filter needs a test to run on each item;
- a join needs a separator, like
", "; - a date formatter needs a language.
A data-last helper takes the setting first. It returns a new unary function that waits for the data. A data-first helper takes the data first and the setting second. The table compares the two. Read each row as: "a helper shaped like this is called like this, and this is how it fits in a pipeline."
| Shape | How you call it | How it fits in a pipeline |
|---|---|---|
| Data-first | filter(items, test) | Easy for one call right now. But it needs the data straight away, so it can't wait in a pipeline. |
| Data-last | filter(test)(items) | Give the setting first. You get back a one-input function that fits into pipe. |
| lodash/fp and Ramda | Every helper is curried and data-last. | You can write pipe(filter(isOpen), map(format)) without ever naming items. |
That is why helper libraries like lodash/fp and Ramda make their helpers data-last and curried. You set up each stage now. Later, pipe passes in the list. The final code can be point-free: you never write a name for the data, such as (items) =>, at each stage.
const getTitle = (lesson) => lesson.title;
const titles = (lessons) => lessons.map(getTitle);
const lessons = [{ title: "Composition" }];
console.log(titles(lessons).join(", "));Line 1 names the function getTitle. Line 2 names the list lessons. That is not point-free, but titles still works fine as a stage in a pipeline. It is often clearer than a row of nameless functions. Point-free style is a choice, not a rule. Keep a name when it explains the data or makes a mistake easier to find.
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). It removes the draft before map picks out the titles.
lessons only appears in the very last call.Data-last is not better than data-first in every case. Data-first calls are simpler when you already have the data. Data-last calls help when you build stages to reuse before the data arrives.
Look inside a pipeline with tap
TRACEA pipeline is easy to read when it works. When it breaks, you can't see the values in the middle. A tap helper fixes that. It takes the current value, logs 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 what console.log(...) returns, the next stage would get undefined.
The return value is what matters. console.log(value) returns undefined. If a stage only calls console.log, the next stage gets undefined. A proper tap returns value after it logs it.
tap returns the 7, while console.log returns undefined, and the next stage gets that.Keep tap small. In code that real users run, use a logger you can switch off, or a debug stage with a clear name that is easy to search for. A pipeline full of logs that never go away is hard to read.
Chain functions that wait, with pipeAsync
ASYNCReal page code often has to wait. It loads a cart, adds prices, checks who the user is, and builds a receipt. A function that waits returns a promise: an object that holds a result that will arrive later. A normal pipe would hand the promise itself to the next stage, not the result inside it.
pipeAsync fixes this. Its accumulator is a promise, so each stage waits for the stage before it to finish.
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 the promise...
Each stage may return a plain value or a promise. pipeAsync keeps a promise as its running result, so each stage waits for the stage before it.
User 42: $12.The stages still run one after another. Use this when stage two really needs the result of stage one. If two requests don't depend on each other, start them together with Promise.all. Otherwise one request waits for the other for no reason.
const pipeAsync = (...fns) => (input) =>
fns.reduce((promise, fn) => promise.then(fn), Promise.resolve(input));
const loadCart = async () => ({ total: 0 });
const requireItems = async (cart) => {
if (cart.total === 0) throw new Error("empty cart");
return cart;
};
pipeAsync(loadCart, requireItems)(undefined)
.then(() => "ready")
.catch((error) => "stopped: " + error.message)
.then(console.log);The first stage, loadCart, returns a cart with a total of 0. So line 5 throws an error. An error thrown inside an async function makes its promise reject, which means it fails. Because of that, the .then on line 10 is skipped. Line 11 catches the failure and turns it into the text stopped: empty cart. In a real app, you might let the error reach the page's error handler instead.
The planned pipeline operator, |>
PROPOSALYou may see |> in blog posts. It is a proposal: a plan for new syntax. TC39, the group that designs JavaScript, is still discussing it. It would let you write pipelines without a helper function.
The official proposal page lists this version, called "Hack-style", at Stage 2. That means it is not finished. It is not part of JavaScript. Node 22 and today's browsers can't run it, unless a tool first rewrites the code.
The official proposal page lists Stage 2. Its examples use % to stand for the current value. The page says this symbol is "almost certainly not" the final one. Treat the code below as a sketch. Use the runnable pipe version in real code.
Source: tc39/proposal-pipeline-operator.
pipe, which runs todayconst 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 code you can run today. The right block is a sketch of a Stage 2 proposal, so it is marked as not runnable.
% to stand for the current value. The proposal says this symbol is not final.The proposal is still worth knowing. It shows what the language wants to make easier: code you read from left to right. Until browsers can run it, any |> example must be marked as not runnable, or shown next to plain JavaScript that does the same thing.
Where pipelines help in real apps
SORT ITPipelines work best when each stage is named after what it does for the app: clean up a page address, keep only published lessons, price a cart, check a form, or add request headers. They work badly when every stage is a nameless trick that the reader must puzzle out line by line.
- Formatting: trim text, clean it up, turn it into a web-address-friendly name, and format it for display.
- Checking input: read it, check it, add details, and return a result object.
- Requests: build a URL, add headers, fetch, read the JSON, and turn errors into messages.
- Page state: run pure changes on the data before you save it as the page's state.
const pipe = (...fns) => (input) =>
fns.reduce((value, fn) => fn(value), input);
const addTax = (cart) => ({ ...cart, total: cart.total * 1.1 });
const formatTotal = (cart) => "$" + cart.total.toFixed(2);
const formatCartTotal = pipe(addTax, formatTotal);
console.log(formatCartTotal({ total: 10 }));Line 3 makes a new cart object with the tax added. It does not change the cart you passed in. Line 4 turns the final number into text, so formatCartTotal prints $11.00. Line 5 gives the whole pipeline a name, so the line that uses it says what it does.
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: does it change the value, only look at it, or break the chain?
Common misconceptions
- "
composeandpipeare two names for the same thing." Both join functions, but they run in opposite directions. - "Point-free code is always cleaner." It is clean when the stage names tell the story. It is hard to read when a named variable would explain the data.
- "A plain log can be a pipeline stage." Only if the stage returns the value it got.
console.logreturnsundefined. - "Async stages work with a normal pipe." A normal
pipepasses the promise along as if it were the value. UsepipeAsync, or write theawaitsteps one by one. - "The pipeline operator is ready, because Babel can read it." Babel is a tool that rewrites new syntax into older JavaScript. A tool that can read
|>is not the same as browsers running it.
When a pipeline goes wrong, check these first. Read each row as: "people often think this, but this is what is true."
| What people mix up | What is true |
|---|---|
compose vs pipe | They do the same job, but run in opposite directions. Pick one for your whole project. |
| Point-free style | It is not always better. Keep a name when it explains the data or helps you spot a wrong number of inputs. |
tap | It only helps if it returns the value it got. A log that returns undefined breaks the chain. |
The |> operator | It is a plan for new syntax, not something you can run today. Use real functions until browsers support it. |
Practice exercises
5 EXERCISESRead the code. Type the number the last line prints.
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, then increment(6) → 7, then square(7) → 49.
Type the exact text this pipeline prints.
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));Memoization is not published, so the filter removes it. The two titles left are joined into Currying -> Composition.
This code prints NaN. Fix the order of the functions so it prints $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 get the number first. formatMoney runs last and turns the taxed number into $11.00.
Type the text this code logs once all the promises finish.
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 cart becomes { userId: 42, items: ["notebook", "pen"], total: 12 }. The last stage turns it into User 42: $12.
A checkout pipeline sometimes gives the wrong total. Which helper lets you add logs between stages without breaking the chain?
const tap = (label) => (value) => { console.log(label, value); return value; };A tap helper, sometimes called trace, is the right tool. It returns the value it got, so the next stage still works.
Check your understanding
8 QUESTIONSFollow one value at a time. For questions that ask what code prints, first write down the order the stages run. For questions about |>, ask: is this real JavaScript today, or a plan that still needs a tool to run?
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
reducemethod matchescompose(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 8Where does the Hack-style pipeline operator
|>stand today?Choose an answer to see the explanation.
Key takeaways
composejoins functions from right to left, usingreduceRight.pipejoins functions from left to right, usingreduce.- Data-last helpers return one-input stages, so you can build pipelines without naming the data.
taplogs a value and returns it unchanged.pipeAsynckeeps a promise as its running result, so each stage waits for the one before it.- The
|>pipeline operator is a Stage 2 proposal. You can't run it as JavaScript today.
Remember the one-liner.
Composition sends one value through small functions in order. Keep the order easy to see, keep the stages pure when you can, and keep proposal syntax out of code you run.
Up next: Memoization & laziness.