JavaScript and TypeScript course Β· Module 12: Functional Programming
Function Composition: compose and pipe
In this lesson5
A species name arrives from a sensor as ' rex ' and has to go through three treatments before it reaches the register. The expression addPrefix(capitalize(normalize(name))) reads from the inside out, and with five stages the parentheses start to look like a tangled strand of DNA. We want to write the stages in the order in which they actually run.
In the genetic laboratory of Jurassic Park, dinosaur DNA goes through a series of processing stages - extraction, purification, sequencing, validation. Each stage is a separate operation, and chaining them together produces the complete process. This is exactly how function composition works - combining simple functions into more complex operations.
What is Function Composition?
Function composition involves combining two or more functions where the output of one becomes the input of the next. Mathematically: f(g(x)) - first we apply g, then f.
Here are three workstations for treating a name: trim removes spaces from both ends, charAt(0).toUpperCase() capitalizes the first letter, and slice(1) appends the rest of the text:
1// Individual operations on dinosaur data
2const normalize = (name) => name.trim().toLowerCase();
3const capitalize = (str) => str.charAt(0).toUpperCase() + str.slice(1);
4const addPrefix = (name) => 'DINO-' + name;Each function is pure and does one thing. Now let's combine them in two ways, first by nesting the calls, then by wrapping them in a named function:
1// Without composition - nested calls (hard to read)
2const result = addPrefix(capitalize(normalize(' rex ')));
3// 'DINO-Rex'
4
5// With composition - readable pipeline
6const formatDinoName = (name) => addPrefix(capitalize(normalize(name)));
7formatDinoName(' rex '); // 'DINO-Rex'Both give 'DINO-Rex'. The second version gives the process a name and can be used over and over, but you still read the order of the stages from right to left. The compose and pipe functions automate this kind of combining for any number of stages.
The compose Function
compose chains functions from right to left - the last function in the list is executed first. This mirrors the mathematical notation f(g(x)).
The rest parameter ...fns gathers all the functions passed in into an array. reduceRight works like reduce, but walks the array from the end, and the initial value of the accumulator is the input data:
1function compose(...fns) {
2 return (value) => fns.reduceRight(
3 (acc, fn) => fn(acc),
4 value
5 );
6}compose itself does not process anything yet, it only returns a new function waiting for data. Let's try it on three stages of sample processing:
1// Functions processing a DNA sample
2const extractDNA = (sample) => ({ ...sample, dna: sample.tissue + '-DNA' });
3const sequenceDNA = (sample) => ({ ...sample, sequence: sample.dna + '-SEQ' });
4const validateDNA = (sample) => ({ ...sample, valid: sample.sequence.length > 5 });
5
6// Compose: right to left (extractDNA -> sequenceDNA -> validateDNA)
7const processSample = compose(validateDNA, sequenceDNA, extractDNA);
8
9const result = processSample({ tissue: 'TREX-001' });
10// { tissue: 'TREX-001', dna: 'TREX-001-DNA', sequence: 'TREX-001-DNA-SEQ', valid: true }extractDNA runs first, because it stands furthest to the right. Each stage returns a new object through spread, so the input sample { tissue: 'TREX-001' } stays untouched, and the result collects the fields added along the way.
The pipe Function
pipe is the inverse of compose - it chains functions from left to right. It reads like a natural data flow, which many programmers find more intuitive.
The implementation differs by one word, reduce instead of reduceRight, and the first function in the list runs first:
1function pipe(...fns) {
2 return (value) => fns.reduce(
3 (acc, fn) => fn(acc),
4 value
5 );
6}
7
8// Pipe: left to right (natural reading direction)
9const processSample = pipe(extractDNA, sequenceDNA, validateDNA);
10// Identical result, but more readable orderThe same three stages give the same result as with compose, but now the order of the arguments matches the order of execution. Stages can also be functions written in place, as in this report of active dinosaurs:
1// Practical example - processing park data
2const processReport = pipe(
3 (data) => data.filter((d) => d.status === 'active'),
4 (data) => data.map((d) => ({ name: d.name, zone: d.zone })),
5 (data) => data.sort((a, b) => a.name.localeCompare(b.name)),
6);
7
8const parkData = [
9 { name: 'Rex', zone: 'A', status: 'active' },
10 { name: 'Stego', zone: 'B', status: 'inactive' },
11 { name: 'Blue', zone: 'A', status: 'active' },
12];
13processReport(parkData);
14// [{ name: 'Blue', zone: 'A' }, { name: 'Rex', zone: 'A' }]Stego drops out at the filter, map keeps the name and the zone, and sort with localeCompare puts the result in alphabetical order. Watch out for a trap: sort changes the array you call it on. Here it is safe, because we sort a fresh array from map, and parkData keeps its original order. If sorting were the first stage, use toSorted (ES2023), which returns a sorted copy.
Compose vs Pipe - When to Use Which?
Both functions do the same thing, but in reverse order. The choice depends on preference, which is easiest to see in a sketch with three stages:
1// compose: right to left (like in mathematics)
2const process1 = compose(validate, transform, parse);
3// Read as: validate(transform(parse(x)))
4
5// pipe: left to right (like a natural flow)
6const process2 = pipe(parse, transform, validate);
7// Read as: x -> parse -> transform -> validateIn practice, pipe is used more often because it mirrors the natural direction of data processing - from input to output, from top to bottom.
How to Build a Pipeline
A proven order of work looks like this: first define a pure function for each step, then test each one separately, next combine them with pipe or compose, and finally pass the input data to the pipeline. Stick to one rule: each stage takes one argument and returns what the next one expects. If one of them forgets its return, the next one receives undefined.
When you want to peek at the data between stages, insert a helper function called tap. It performs a side effect but passes on exactly the same value:
1// Peeking at data between stages, without changing the result
2const tap = (fn) => (value) => {
3 fn(value);
4 return value;
5};
6
7const debugReport = pipe(
8 (data) => data.filter((d) => d.status === 'active'),
9 tap((data) => console.log('After filter:', data.length)),
10 (data) => data.map((d) => d.name),
11);
12
13debugReport(parkData); // ['Rex', 'Blue'], console shows: After filter: 2The console shows the number of active dinosaurs, and the final result is still a plain list of their names - tap did not change anything in it. I recommend keeping tap at hand while debugging and removing it before deployment. In the next lesson you will see how currying turns multi-argument functions into single-argument ones that fit pipe perfectly.
Remember: pipe is a conveyor belt in the laboratory - the sample rides in from the left, passes through the workstations one after another and rides out on the right as a finished result.
Code for this lesson: index.js
1// Function composition: compose and pipe
2console.log("=== Jurassic Park - DNA Recombination ===\n");
3
4// compose implementation (right -> left)
5function compose(...fns) {
6 return (value) => fns.reduceRight((acc, fn) => fn(acc), value);
7}
8
9// pipe implementation (left -> right)
10function pipe(...fns) {
11 return (value) => fns.reduce((acc, fn) => fn(acc), value);
12}
13
14// --- EXAMPLE 1: Formatting names ---
15console.log("--- Compose: Formatting names ---");
16const normalize = (str) => str.trim().toLowerCase();
17const capitalize = (str) => str.charAt(0).toUpperCase() + str.slice(1);
18const addPrefix = (str) => "DINO-" + str;
19
20const formatName = compose(addPrefix, capitalize, normalize);
21console.log(formatName(" rex ")); // DINO-Rex
22console.log(formatName(" VELOCIRAPTOR ")); // DINO-Velociraptor
23
24// --- EXAMPLE 2: A DNA processing pipeline ---
25console.log("\n--- Pipe: Processing a DNA sample ---");
26
27const extractDNA = (sample) => ({ ...sample, dna: sample.tissue + "-DNA" });
28const sequenceDNA = (sample) => ({ ...sample, sequence: sample.dna + "-SEQ" });
29const assessQuality = (sample) => ({
30 ...sample,
31 quality: sample.sequence.length > 15 ? "HIGH" : "LOW",
32});
33const createReport = (sample) => ({
34 ...sample,
35 report: sample.quality + " quality sample from " + sample.tissue,
36});
37
38const processSample = pipe(extractDNA, sequenceDNA, assessQuality, createReport);
39
40const result = processSample({ tissue: "TREX-001" });
41console.log("Result:", JSON.stringify(result, null, 2));
42
43// --- EXAMPLE 3: Processing park data ---
44console.log("\n--- Pipe: Security report ---");
45
46const parkData = [
47 { name: "Rex", zone: "A", status: "active", danger: 9 },
48 { name: "Brachio", zone: "B", status: "active", danger: 2 },
49 { name: "Raptor", zone: "A", status: "escaped", danger: 8 },
50 { name: "Stego", zone: "B", status: "active", danger: 3 },
51 { name: "Blue", zone: "A", status: "active", danger: 7 },
52];
53
54const generateReport = pipe(
55 (data) => data.filter((d) => d.status === "active"),
56 (data) => data.filter((d) => d.danger >= 5),
57 (data) => data.map((d) => ({ name: d.name, zone: d.zone, level: d.danger > 7 ? "CRITICAL" : "HIGH" })),
58 (data) => [...data].sort((a, b) => a.name.localeCompare(b.name)),
59);
60
61const report = generateReport(parkData);
62console.log("Active threats:");
63report.forEach((r) => console.log(` [${r.level}] ${r.name} - Zone ${r.zone}`));
64
65// --- EXAMPLE 4: Compose vs Pipe ---
66console.log("\n--- Comparing compose vs pipe ---");
67const double = (x) => x * 2;
68const addTen = (x) => x + 10;
69const square = (x) => x * x;
70
71const withCompose = compose(square, addTen, double);
72const withPipe = pipe(double, addTen, square);
73
74console.log("compose(square, addTen, double)(3):", withCompose(3));
75console.log("pipe(double, addTen, square)(3):", withPipe(3));
76console.log("Both: 3 -> double(6) -> addTen(16) -> square(256)");Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. In what order does pipe execute functions?
2. What is the difference between compose and pipe?
These are 2 of 3 questions for this lesson. Solve the rest in the game.
Hands-on tasks in the game
- Code editor
Write normalizing and classifying functions, then combine them using pipe.
- Horizontal ordering
Arrange the elements of the pipe function implementation:
- Code editor
Group the data by zone, calculate statistics per sensor type, and count alerts.