Learn JS Series (#27) - Function Composition: Building Pipelines from Small Functions
Learn JS Series (#27) - Function Composition: Building Pipelines from Small Functions
What will I learn
- You will learn what function composition is: combining small functions into a bigger one;
- the difference between
compose(right to left) andpipe(left to right); - how to write generic
composeandpipehelpers withreduce; - how composition turns a sequence of transformations into a readable data pipeline;
- why small, single-purpose, pure functions are the raw material composition needs.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Node.js (20+) distribution, or just a modern browser console;
- Episodes 1-26 read, especially higher-order functions, closures, and currying.
Difficulty
- Intermediate
Curriculum (of the Learn JS Series):
- Learn JS Series (#1) - What Is JavaScript, Why It Runs Everywhere, and How to Run It
- Learn JS Series (#2) - Variables and Bindings
- Learn JS Series (#3) - The Primitive Types: number, string, boolean, null, undefined, symbol, bigint
- Learn JS Series (#4) - Operators and Expressions: Arithmetic, Comparison, Logical, and Short-Circuiting
- Learn JS Series (#5) - Strings: Template Literals, Unicode, and the Methods You Actually Use
- Learn JS Series (#6) - Numbers: IEEE 754, Why 0.1 + 0.2 Is Not 0.3, and How to Cope
- Learn JS Series (#7) - Control Flow: if/else, switch, and the Ternary Expression
- Learn JS Series (#8) - Loops: for, while, for...of, for...in, and When to Use Which
- Learn JS Series (#9) - Functions: Declarations, Parameters, Return Values, and Hoisting
- Learn JS Series (#10) - Scope and the Temporal Dead Zone: How JavaScript Finds Your Variables
- Learn JS Series (#11) - Arrays: The Workhorse Data Structure and Its Core Methods
- Learn JS Series (#12) - Objects: Key-Value Data, Dot vs Bracket Access, and Nesting
- Learn JS Series (#13) - Truthiness, Equality, and Coercion: == vs === Done Properly
- Learn JS Series (#14) - Mini Project: A Command-Line Tip Calculator
- Learn JS Series (#15) - First-Class Functions: Passing, Returning, and Storing Functions
- Learn JS Series (#16) - Arrow Functions vs function: Syntax, this, and When Each Wins
- Learn JS Series (#17) - Closures: The Single Most Important Idea in JavaScript
- Learn JS Series (#18) - Higher-Order Functions: Functions That Take or Return Functions
- Learn JS Series (#19) - Callbacks and the Callback Pattern (Before We Reach Promises)
- Learn JS Series (#20) - Default, Rest, and Spread: Flexible Function Signatures
- Learn JS Series (#21) - Destructuring Parameters: Named Arguments the JS Way
- Learn JS Series (#22) - The this Keyword: Five Rules That Explain Every Case
- Learn JS Series (#23) - call, apply, and bind: Controlling this Explicitly
- Learn JS Series (#24) - Recursion: Base Cases, the Call Stack, and Stack Overflows
- Learn JS Series (#25) - IIFEs and the Module Pattern (the Pre-2015 Way to Get Privacy)
- Learn JS Series (#26) - Currying and Partial Application
- Learn JS Series (#27) - Function Composition: Building Pipelines from Small Functions (this post)
Learn JS Series (#27) - Function Composition: Building Pipelines from Small Functions
Solutions to Episode 26 Exercises
Exercise 1 - curried multiply:
const curriedMultiply = (a) => (b) => (c) => a * b * c;
const timesSix = curriedMultiply(2)(3);
console.log(timesSix(4)); // 24
console.log(timesSix(10)); // 60
The insight: stopping after two calls yields a reusable function with a and b baked in.
Exercise 2 - generic curry on volume:
function curry(fn) {
return function curried(...args) {
return args.length >= fn.length ? fn(...args) : (...more) => curried(...args, ...more);
};
}
const volume = (l, w, h) => l * w * h;
const cv = curry(volume);
console.log(cv(2)(3)(4), cv(2, 3)(4), cv(2, 3, 4)); // 24 24 24
The insight: the helper collects arguments until it has fn.length of them, then calls the real function, accepting any grouping.
Exercise 3 - a curried prop getter:
const prop = (key) => (obj) => obj[key];
const getCity = prop("city");
const users = [{ city: "amsterdam" }, { city: "berlin" }];
console.log(users.map(getCity)); // ["amsterdam", "berlin"]
The insight: currying takes one argument per call (prop("city") then (obj)), whereas partial application fixes some arguments at once, leaving the rest to be passed together.
Last episode we ended on a promise. We built curried, single-argument functions and I told you they were the ideal Lego bricks for something coming next. Well, this is that next -- function composition is the glue that snaps those bricks together. Having said that, composition is one of those ideas that is almost embarrassingly simple once you see it, and yet it quietly reshapes how you structure entire programs. Let's build it up from nothing.
What composition actually is
Function composition is the act of combining two or more functions so that the output of one becomes the input of the next, producing a single new function that runs all of them in sequence. It is the exact same idea you met in maths class (probably without loving it at the time): given f and g, the composition f(g(x)) first applies g to x, then feeds that result into f.
The whole point of composition is to build complicated behaviour out of tiny, single-purpose functions -- episode 9's "keep functions small" rule finally paying dividends. Each function does one obvious thing; composition chains them into a bigger transformation without any of the pieces knowing about each other. Here is the idea done entirely by hand, no helpers yet:
const trim = (s) => s.trim();
const lower = (s) => s.toLowerCase();
const exclaim = (s) => `${s}!`;
// compose them manually, inside out:
const shout = (s) => exclaim(lower(trim(s)));
console.log(shout(" Hello ")); // "hello!"
shout first trims, then lowercases, then adds an exclamation mark -- three small functions acting as one. Writing exclaim(lower(trim(s))) works fine here, but notice how you have to read it inside out (the innermost call runs first), and notice how ugly it gets the moment you have five or six steps: f(g(h(i(j(x))))) is a parenthesis-counting nightmare. So, like any good programmer faced with a repetitive pattern, we abstract it into a helper.
compose: right to left
A compose helper takes several functions and returns a new function that applies them from right to left, matching the mathematical f(g(x)) convention -- the rightmost function runs first, because it sits closest to the input:
function compose(...fns) {
return function (input) {
return fns.reduceRight((value, fn) => fn(value), input);
};
}
const shout = compose(exclaim, lower, trim);
console.log(shout(" Hello ")); // "hello!" (trim runs first, then lower, then exclaim)
Look closely at what is happening: reduceRight (the mirror image of the reduce we met in episode 11) walks the functions array from the end toward the start, threading each result into the next function leftward. So compose(exclaim, lower, trim) reads exactly like the maths: exclaim(lower(trim(x))). The rightmost function is applied first.
This matches decades of mathematical tradition, which is why libraries offer it. But I will be honest with you -- quite some people (myself included, on a Monday) find the right-to-left reading order faintly annoying for everyday data work. You write the steps in the order exclaim, lower, trim but they execute in the reverse order. Your eyes go one way, the data goes the other. And that little friction is precisely why its twin sister exists.
pipe: left to right
pipe is the same machinery with the direction flipped: it applies functions left to right, in the order you actually read them, which most people find far more natural for describing a sequence of transformations. It is compose with reduce in stead of reduceRight:
function pipe(...fns) {
return function (input) {
return fns.reduce((value, fn) => fn(value), input);
};
}
const shout = pipe(trim, lower, exclaim);
console.log(shout(" Hello ")); // "hello!" (reads top to bottom: trim, then lower, then exclaim)
Now pipe(trim, lower, exclaim) reads in execution order: trim, then lower, then exclaim. Left to right, top to bottom, the way you read a recipe or a to-do list. This is why pipe has quietly become the more popular of the two in modern JavaScript, and why the TC39 committee has spent years debating a dedicated pipeline operator (|>) to bake this pattern into the language itself. Under the hood there is no magic whatsoever -- it is just reduce walking the functions and threading a single value through each one.
Both helpers are two lines of code. That is worth sitting with for a second: the entire concept of "wire functions together into a pipeline" is a reduce and nothing more. When people say functional programming is "just" a handful of small ideas combined relentlessly, this is the kind of thing they mean. ;-)
Composition as a data pipeline
The real beauty shows up when you have a genuine sequence of transformations to run on some data. Instead of a tangle of nested calls, or a staircase of intermediate variables each used exactly once, you describe the pipeline as a plain list of steps and read it straight down:
const parseNumbers = (s) => s.split(",").map(Number);
const keepPositive = (nums) => nums.filter((n) => n > 0);
const sum = (nums) => nums.reduce((a, b) => a + b, 0);
const totalPositive = pipe(parseNumbers, keepPositive, sum);
console.log(totalPositive("3,-1,4,-2,5")); // 12 (3 + 4 + 5)
Read pipe(parseNumbers, keepPositive, sum) as a recipe in three lines of English: parse the string into numbers, keep the positive ones, add them up. Each step is a small, independently testable function -- I can unit-test keepPositive in complete isolation, without ever thinking about strings or sums. The pipeline merely assembles them into a flow.
Compare that to the imperative version most of us would write on autopilot:
function totalPositiveImperative(s) {
const parsed = s.split(",").map(Number);
const positives = parsed.filter((n) => n > 0);
let running = 0;
for (const n of positives) running += n;
return running;
}
console.log(totalPositiveImperative("3,-1,4,-2,5")); // 12
There is nothing wrong with that second version, and for a one-off it is perfectly clear. But notice what the composed version gives you that this one does not: the steps are named, reusable functions living outside the pipeline, and adding a new step (say, "round each number first") is just dropping one more function into the list -- not surgery on a loop body. Composition scales by addition, not by rewriting. That is the property that matters as programs grow.
Composition loves single-argument functions
Composition works most smoothly when each function takes exactly one input and returns one output, because the single output of each step feeds directly into the single input of the next. No juggling, no "wait, which of the three arguments does this one need". And this is precisely why currying (episode 26) and composition are such natural partners -- a curried function, once you partially apply it, becomes a tidy one-argument function ready to drop straight into a pipe:
const multiplyBy = (factor) => (n) => n * factor; // curried
const add = (amount) => (n) => n + amount; // curried
const transform = pipe(
multiplyBy(2), // (n) => n * 2
add(10), // (n) => n + 10
multiplyBy(3) // (n) => n * 3
);
console.log(transform(5)); // ((5 * 2) + 10) * 3 = 60
Each curried function is configured with its parameter up front (multiplyBy(2)), yielding a clean one-argument function that slots into the pipe like it was made for it -- because it was. Curry to specialize, compose to sequence. That little pairing is genuinely the beating heart of functional JavaScript, and Phase 10 (still some episodes away) builds an entire practical toolkit on exactly this foundation.
There is a subtle trap worth flagging here, because it bites everyone once. Composition threads one value through the chain, so every function in a pipe must accept one argument and hand back one value that the next step can use. If a step returns undefined (a common accident -- forgetting a return, or using forEach where you meant map), the rest of the pipeline silently receives undefined and things go quietly wrong. When a pipe misbehaves, the very first thing I check is: does every single step actually return something? Nine times out of ten, that is the bug.
Keep the pieces small and pure
Composition is only ever as good as the functions you compose. Two habits make it genuinely shine. First, keep each function small and single-purpose (episode 9 again -- it keeps coming back for a reason), so the pipeline reads like a clear list of intentions. Second, and this is the one we will make rigorous next episode: prefer pure functions, functions with no side effects whose output depends only on their input.
Pure, single-purpose functions compose predictably, because each step is a self-contained transformation with no hidden state and no surprises reaching in from outside. Watch how effortlessly tiny pure building blocks snap into anything you like:
// small, pure building blocks compose into anything:
const words = (s) => s.split(" ");
const count = (arr) => arr.length;
const wordCount = pipe(trim, words, count);
console.log(wordCount(" learn javascript today ")); // 3
And because the pieces are so small, you recombine them for free. The very same trim and words we already wrote can be reused in a completely different pipeline without touching them:
const upperEach = (arr) => arr.map((w) => w.toUpperCase());
const join = (arr) => arr.join(" ");
const shoutWords = pipe(trim, words, upperEach, join);
console.log(shoutWords(" learn javascript today ")); // "LEARN JAVASCRIPT TODAY"
Same trim, same words, brand new behaviour -- assembled, not rewritten. When composition starts feeling awkward, when a step refuses to slot in cleanly, it is almost always a signal that one of your functions is doing too much or reaching outside itself for state. Fix the pieces, and the pipeline falls into place. That is the real mindset shift functional JavaScript rewards: build small, honest, boring functions, then assemble the interesting behaviour out of them.
How other languages handle this
Since quite a few of you wandered in from the Learn Python Series (with the odd Rust and Go reader in tow), a look sideways is illuminating -- because JavaScript is far from alone here, and seeing the same idea in three other languages nails it down for good.
Python has no compose or pipe in the language itself, but the pattern is so common that people reach for functools.reduce to build one, exactly as we did with JS's reduce:
from functools import reduce
def pipe(*fns):
return lambda x: reduce(lambda acc, fn: fn(acc), fns, x)
trim = str.strip
lower = str.lower
exclaim = lambda s: s + "!"
shout = pipe(trim, lower, exclaim)
print(shout(" Hello ")) # "hello!"
That is our pipe almost line for line -- same reduce, same left-to-right threading, just spelled in Python. (Pythonistas often prefer a plain sequence of statements or a comprehension, mind you; composition is available, not mandatory.)
Rust does not have a built-in compose either, but its iterator adaptors are composition in disguise -- each .map(...).filter(...) chains a transformation onto the previous one, left to right, which is pipe by another name:
fn main() {
let total: i32 = "3,-1,4,-2,5"
.split(',')
.filter_map(|s| s.parse::<i32>().ok())
.filter(|&n| n > 0)
.sum();
println!("{}", total); // 12
}
Read that chain top to bottom and it is our totalPositive pipeline exactly: parse, keep positives, sum. The steps flow left to right down the method chain, each one feeding the next.
Haskell, the functional purist of the family, makes composition a first-class operator: the dot. (f . g) x means f (g x) -- literally the mathematical compose, built into the syntax. Where JavaScript writes a helper function, Haskell writes a single character. That tells you how central this idea is to the functional tradition: it is not a pattern bolted on, it is part of the grammar.
The takeaway is the portable one: composition is a universal idea, and once you understand it as "thread a value through a list of functions", you will recognise it everywhere -- as a helper in JS, as functools.reduce in Python, as iterator chains in Rust, as an operator in Haskell. The syntax is just local dialect.
When NOT to reach for compose
Now the honest part, because I would be doing you a disservice to sell composition as a hammer for every nail. Composition earns its keep when you have a real sequence of transformations on a single value, and when the individual steps are meaningful enough to name and reuse. It is a poor fit when:
- The logic branches heavily (lots of
if this then a whole different path). Composition is a straight line; genuinely branchy logic reads better as ordinary code. - A step needs two or three unrelated inputs at once. Composition threads one value; forcing multi-argument steps into a pipe leads to awkward tuple-juggling.
- There is only one step.
pipe(double)(5)is justdouble(5)wearing a silly hat. Do not add ceremony for nothing.
For a plain, one-off transformation, a normal function call or a simple statement is clearer, full stop. Like every tool in this series, composition is excellent in its place and pure noise everywhere else. Aim for clarity, not cleverness -- the reader you are helping most is usually yourself, six months from now, squinting at a forementioned pipeline and trying to remember what it was for. ;-)
Try it yourself
Three exercises, increasing in difficulty. Try to predict each result before you run it -- full solutions open the next episode.
- Write three small functions
double,increment, andsquare, then use your ownpipehelper to build a function that doubles, then increments, then squares a number. Test it with3(expect((3*2)+1)^2 = 49). - Implement both
composeandpipeusingreduceRightandreducerespectively. Show thatcompose(f, g, h)(x)andpipe(h, g, f)(x)produce the same result for three simple functions, and explain in one sentence why the argument order is reversed between them. - Build a text-processing pipeline with
pipethat takes a sentence, trims it, splits it into words, and returns the number of words longer than three letters. Use a small single-purpose function for each step, and explain why keeping every step pure makes the pipeline easy to reason about.
So what did we actually cover?
- Function composition combines small functions so that each one's output feeds the next, building complex behaviour out of simple, independently testable parts.
compose(...fns)applies functions right to left (the maths convention,f(g(x))) usingreduceRight.pipe(...fns)applies functions left to right (reading order), usingreduce, which most people find far more natural for data transformations.- Composition turns a sequence of transformations into a readable data pipeline, where adding a step is just adding a function -- it scales by addition, not rewriting.
- Composition pairs naturally with currying: partially-applied curried functions become the one-argument bricks that pipes are built from. Curry to specialize, compose to sequence.
- Small, single-purpose, pure functions compose predictably; awkward composition usually signals a function doing too much or reaching outside itself, and every step MUST return a value.
- The idea is universal -- Python builds it with
functools.reduce, Rust chains iterator adaptors, Haskell has a.operator -- so the concept travels even when the syntax does not.
Next episode we make that word "pure" precise, instead of hand-waving at it. Pure functions and side effects are the foundation of predictable, testable code, and the core discipline underneath everything functional -- once you can spot a side effect at a glance, a surprising amount of "mysterious" bugs stop being mysterious.