cf.completefrontendCode editorOpen lab
THE JAVASCRIPT FIELD GUIDE

Composition & pipelines

Chain small JavaScript functions with compose and pipe, build them with reduce, debug pipelines, handle async flows, and understand the pipeline proposal.

By the end, you can
  • 01
    Build function pipelinesImplement compose and pipe from scratch with reduceRight and reduce.
  • 02
    Choose composable shapesExplain why data-last helpers enable point-free pipelines in libraries like lodash/fp and Ramda.
  • 03
    Debug and modernize safelyUse tap, write pipeAsync, 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.

Definition

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.

Four ways to read a chain of transformations
FormDirectionHow to read it
compose(a, b, c)(x)Right to leftReads like nested calls: a(b(c(x))).
pipe(a, b, c)(x)Left to rightReads like a data flow: x then a, then b, then c.
Method chainingLeft to rightWorks only when the current value owns the next method.
Pipeline operator proposalLeft to rightProposed 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

ANALOGY
Real-life analogyAn assembly line with labeled stations

Picture 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 tap stage 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 THROUGH

compose 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.

Compose lab: right to left
Step 0 of 8Ready
Your turn: follow the blue line

Step through compose(square, increment, double). The code is built with reduceRight, so execution starts at the right edge and travels left.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
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));
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
Choose the input to send into `compose(square, increment, double)`

Changing the input starts a fresh replay. The function order stays the same.

A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

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.

Pipe lab: left to right
Step 0 of 8Ready
Your turn: follow the blue line

Step through pipe(trim, lower, dash). This time reduce runs left to right, so each output becomes the next input.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
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  "));
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
Choose the text to slugify

`pipe` keeps the displayed order and execution order aligned.

A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.
Choose one convention per codebase

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.

  1. pipe uses reduce. It starts at the first function in the array and walks forward.
  2. compose uses reduceRight. It starts at the last function in the array and walks backward.
  3. The initial accumulator is the input. Without that starting value, the first function would not receive the original data.
Reducer line in plain English

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

INTERACTIVE

A 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.

Data-first and data-last helper shapes
ShapeExampleComposition effect
Data-firstfilter(items, predicate)Natural for one immediate call, but the data argument blocks direct point-free composition.
Data-lastfilter(predicate)(items)Configure the helper first; the returned unary function fits into pipe.
lodash/fp and RamdaHelpers are curried and data-lastThey 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.

Data-last helper lab
Data-last pipelinePop out in the code editor (opens in a new tab)JavaScript
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));
Outputpublished

Currying -> Composition

Try it yourself

The first stage is filter((lesson) => lesson.published), so the draft is removed before titles are mapped.

The code uses curried data-last helpers, so 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

TRACE

Pipelines 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.

Trace a pipeline with tap
Tap and trace helperPop out in the code editor (opens in a new tab)JavaScript
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("|"));
Consoletrace
  1. normalized: compose small functions well
  2. words: compose,small,functions,well
  3. filtered: compose,small,functions,well
  4. compose|small|functions|well
Try it yourself
Real output from the snippet

A tap stage logs the value and returns it unchanged. If it returned console.log(...), the next stage would receive undefined.

The output list is the exact sequence produced by the displayed code.

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.

Trace locally, remove or gate in production

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

ASYNC

Real 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.

Async pipeline with pipeAsync
pipeAsync sourcePop out in the code editor (opens in a new tab)JavaScript
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();
Resolved outputasync

waiting for promise...

Try it yourself
Promise chain

Each stage may return a value or a promise. The reducer keeps a promise accumulator so the next stage waits for the previous result.

The demo uses fake async functions, but this is the same shape you can use for request, validate, format flows.

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

PROPOSAL

You 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.

Status as of September 26, 2026

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.

Proposal syntax beside runnable code
Runnable pipe equivalentPop out in the code editor (opens in a new tab)JavaScript
const 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);
Hack-style pipeline proposal sketchJavaScript
const slug = "  Hello Pipeline  "  |> %.trim()  |> %.toLowerCase()  |> %.replaceAll(" ", "-"); console.log(slug);
Runnable outputpipe

hello-pipeline

Try it yourself
Standard JavaScript vs proposal syntax

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.

The proposal README currently uses % 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 IT

Composition 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.
Is this a safe pipeline stage?
  • 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")
Try it yourself
0 of 6 correct

Sort each snippet by whether it transforms the value, only observes it, or breaks the flow.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common misconceptions

  • “compose and pipe are 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.log returns undefined.
  • “Async stages work with a normal pipe.” A normal pipe passes promises as values. Use pipeAsync or write the await sequence directly.
  • “The pipeline operator is ready because Babel can parse it.” Tooling experiments are not the same as JavaScript engines shipping a standard feature.
Pipeline mistakes to check first
ConfusionReality
compose vs pipeSame goal, opposite reading direction. Pick one convention per codebase.
Point-freeNot automatically better. Keep names when they explain the data or avoid arity bugs.
tapUseful only when it returns the original value. A logging function that returns undefined breaks the flow.
Pipeline operatorSyntax proposal, not a runtime feature today. Use real functions until engines ship it.

Practice exercises

5 EXERCISES
Exercise 1 · Warm-upPredict a composed number

Read the code and type the number printed by the final line.

Starter codePop out in the code editor (opens in a new tab)JavaScript
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));

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · PracticeRead a data-last pipeline

    Predict the exact text printed by the data-last helper pipeline.

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    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));

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeFind the composition order bug

      The starter prints NaN. Fix the composition order so the printed receipt is $11.00.

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      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));

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeTrace an async receipt

        Type the text the promise chain eventually logs.

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        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);

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeChoose the debugging helper

          A checkout pipeline sometimes produces the wrong total. Which helper lets you insert temporary logs between stages without breaking the flow?

          Answer, then press Check. Spacing and letter case don’t matter.

            Check your understanding

            8 QUESTIONS

            Trace 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.

            Lesson quiz · 8 questionsScore: first tries count
            1. Question 1 of 8Which sentence best describes function composition?

              Choose an answer to see the explanation.

            2. Question 2 of 8What does this composed call print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              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);
              console.log(compose(square, increment, double)(3));

              Choose an answer to see the explanation.

            3. Question 3 of 8What does this pipe print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const 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.

            4. Question 4 of 8Why do lodash/fp and Ramda prefer data-last helpers?

              Choose an answer to see the explanation.

            5. Question 5 of 8Which reducer direction matches compose(a, b, c)(x)?

              Choose an answer to see the explanation.

            6. Question 6 of 8What must a tap helper return?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const tap = (label) => (value) => {
                console.log(label + ":", value);
                return value;
              };
              console.log(tap("seen")(7) * 2);

              Choose an answer to see the explanation.

            7. Question 7 of 8What does this async pipeline eventually print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const 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.

            8. Question 8 of 8What is the honest status of the Hack-style pipeline operator today?

              Choose an answer to see the explanation.

            Key takeaways

            • compose chains functions right to left with reduceRight.
            • pipe chains functions left to right with reduce.
            • Data-last helpers return unary stages, which makes point-free pipelines possible.
            • tap logs or traces while returning the original value unchanged.
            • pipeAsync uses 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.

            CompleteFrontend Clear concepts. Working examples.