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.

The Unix insight: This is exactly the philosophy of Unix pipes โ€” cat file | grep error | sort | uniq -c. Small tools, standard interfaces, composed into infinite workflows. FP is that idea taken to the function level.
order โ†’ validate pure ยท testable discount pure ยท testable tax pure ยท testable format pure ยท testable receipt adding a stage = adding a line, not rewriting a function swap order freely reuse in other pipes test each in isolation

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:

Further Reading