Composition & Pipelines
Lesson 4: Composition & Pipelines
If pure functions are the atoms, composition is the chemistry. The whole point of writing small, single-purpose functions is to snap them together into larger ones. A program built this way isn't a sequence of steps โ it's a description of how data flows.
Function Composition
Composing functions means feeding the output of one into the next: h(x) = f(g(x)). In code:
const compose = (f, g) => x => f(g(x));
// trim, then uppercase
const normalize = compose(str => str.toUpperCase(), str => str.trim());
normalize(" hello "); // "HELLO"
And its more readable twin โ the pipeline, where data flows left to right, top to bottom:
const pipe = (...fns) => x => fns.reduce((v, f) => f(v), x);
const processOrder = pipe(
validate, // order โ checked order
applyDiscount, // checked order โ discounted order
computeTax, // discounted order โ order with tax
formatReceipt // order โ printable receipt string
);
const receipt = processOrder(order);
Read that like a spec: "processOrder = validate, then applyDiscount, then computeTax, then formatReceipt." Each stage is a pure function; the pipeline is just the plumbing between them.
cat file | grep error | sort | uniq -c. Small tools, standard interfaces, composed into infinite workflows. FP is that idea taken to the function level.
Why This Beats Nested Ifs and Flag Variables
- Explicit data flow: every transformation a value goes through is visible in the pipeline โ no hidden mutations between steps.
- Independent stages: each function is testable alone and reusable in other pipelines.
- Cheap extension: adding a stage is adding a line, not rewriting a function or threading new parameters through ten call sites.
- Reading order: top-to-bottom matches the order of operations. No jumping between nested blocks.
Imperative vs Pipeline โ Same Job
// IMPERATIVE: flags, mutation, and bookkeeping
function processOrder(order) {
if (!order.items || order.items.length === 0) return null;
let total = 0;
let discounted = false;
for (const item of order.items) {
let price = item.price;
if (item.qty > 5) {
if (!discounted) {
price = price * 0.9;
discounted = true;
}
}
total += price * item.qty;
}
total += total * 0.08;
return "Total: $" + total.toFixed(2);
}
// PIPELINE: each concern is its own named, reusable stage
const applyDiscount = order => ({
...order,
items: order.items.map(item =>
item.qty > 5 ? { ...item, price: item.price * 0.9 } : item)
});
const computeTotal = order =>
order.items.reduce((sum, i) => sum + i.price * i.qty, 0);
const withTax = total => total * 1.08;
const format = total => "Total: $" + total.toFixed(2);
const processOrder2 = pipe(applyDiscount, computeTotal, withTax, format);
Both work. The pipeline version names each rule, keeps them independent, and reads like the business spec. When the discount rule changes, you edit applyDiscount โ not the loop.
๐ง Knowledge Check
1. If pipe(f, g, h)(x) runs f first, then g, then h, the result is:
2. What is the main advantage of pipeline-style code over nested imperative code?
3. The Unix pipeline cat file | grep error | sort illustrates: