DEV Community

Cover image for The Secret Life of JavaScript: The Power of Function Composition
Aaron Rose
Aaron Rose

Posted on

The Secret Life of JavaScript: The Power of Function Composition

Function Composition: Building complex operations by chaining simple functions together, where the output of one becomes the input of the next.


Timothy was not having a good time. He was staring at a block of code that looked less like programming and more like a math equation that had been involved in a traffic accident.

"I hate this," he muttered.

Margaret paused by his desk, raising an eyebrow. "Hate is a strong word, Timothy. What did the code do to you?"

"It reads backwards!" Timothy pointed to the screen. "I wrote these nice little helper functions to format my user data, but combining them is a nightmare. It’s like peeling an onion, but the onion makes me cry."

He highlighted the code:

const userInput = "   gandalfthegrey  ";

// My helper functions
const trim = str => str.trim();
const upperCase = str => str.toUpperCase();
const exclaim = str => `${str}!`;
const bold = str => `<b>${str}</b>`;

// The "Onion" of code
const onionResult = bold(upperCase(exclaim(trim(userInput))));

console.log(onionResult); // "<b>GANDALFTHEGREY!</b>"

Enter fullscreen mode Exit fullscreen mode

"Ah," Margaret nodded. "The 'Inside-Out' problem. You want to trim the string, then uppercase it, then add an exclamation point, then bold it. But you have to write it in reverse order."

"And if I want to add another step?" Timothy asked. "I have to find the exact middle of that nest and shove it in. It's messy."

"It is," Margaret agreed. "You are writing code for the computer, not for the human. The computer loves nesting. Humans love sequences. We need to turn that onion into a pipeline."

The Concept: The Pipeline

Margaret grabbed a marker and sketched a quick diagram on the whiteboard comparing the two approaches.

The "Onion" (Nested)      vs.      The Pipeline (Composed)
Reads Inside-Out                   Reads Top-to-Bottom

      bold(                         "  data  "
        upperCase(                      |
          trim(                         V
            " data "               +----------+
          )                        |   trim   |
        )                          +----------+
      )                                 |
                                        V
                                   +-----------+
                                   | upperCase |
                                   +-----------+
                                        |
                                        V
                                   +----------+
                                   |   bold   |
                                   +----------+
                                        |
                                        V
                                    "<b>DATA</b>"

Enter fullscreen mode Exit fullscreen mode

"Imagine your data is water," she explained, pointing to the right side of the diagram. "Function composition is plumbing. We connect the output of one function directly to the input of the next. The data flows through smoothly, top to bottom."

"That looks much better," Timothy admitted. "But JavaScript doesn't have pipes natively."

"It doesn't have a built-in pipe operator today," Margaret clarified. "But we can build the behavior ourselves. This is one of the superpowers of functional programming."

Building the pipe

Margaret opened a new file. "We need a function that takes a list of functions and runs them in order. Let's write a version called pipeLoop first so you see exactly what's happening."

// The "Boring" (but clear) Pipe
const pipeLoop = (...functions) => {
    return (initialValue) => {
        let result = initialValue;

        // Loop through every function in the list
        for (let func of functions) {
            // Pass the result of the last function into the next one
            result = func(result);
        }

        return result;
    };
};

Enter fullscreen mode Exit fullscreen mode

"It's just a loop," Margaret explained. "It takes your initial value, passes it to the first function, takes that result and passes it to the next function, and keeps going until it runs out of functions. It’s an assembly line."

Timothy nodded. "Okay, that makes sense. It's just updating the result variable over and over."

"Exactly," Margaret said. "And because we are JavaScript developers and we love one-liners, we usually write it like this using reduce. It does the exact same thing as pipeLoop. Since they are equivalent, we'll use this cleaner pipe version for our real code:"

// The "Pro" Pipe
const pipe = (...functions) => (initialValue) => 
    functions.reduce((currentValue, currentFunction) => currentFunction(currentValue), initialValue);

Enter fullscreen mode Exit fullscreen mode

She refactored Timothy's messy onion using the new tool:

// Step 1: We reuse the small functions (trim, bold, etc.) you defined earlier.

// Step 2: Create the pipeline
const formatString = pipe(
    trim,
    upperCase,
    exclaim,
    bold
);

// Step 3: Use it
const result = formatString("   gandalfthegrey  ");
console.log(result);

Enter fullscreen mode Exit fullscreen mode

"Whoa," Timothy said. "That reads exactly like English. Trim, Uppercase, Exclaim, Bold. Top to bottom."

But how do I debug it?

Timothy frowned slightly. "But Margaret, in my 'onion' code, I could just console.log the variable in the middle to see what was happening. Here, the data is hidden inside the pipe. I can't see it."

"We make a window," Margaret said. She wrote a tiny helper function:

const trace = label => value => {
    // We use comma separation so objects log correctly
    console.log(label, value);
    return value;
};

Enter fullscreen mode Exit fullscreen mode

"It just logs the value and passes it through untouched. Now you can spy on your data without breaking the pipe:"

const formatStringWithTrace = pipe(
    trim,
    trace("After Trim"),   // <--- Logs: After Trim gandalfthegrey
    upperCase,
    trace("After Upper"),  // <--- Logs: After Upper GANDALFTHEGREY
    bold
);

console.log("Final:", formatStringWithTrace("   gandalfthegrey  "));

Enter fullscreen mode Exit fullscreen mode

Timothy saw the logs appear in the console before the final result. "Okay, that is actually incredibly useful."

The "Aha!" Connection: Why we needed Currying

"Now for the final piece," Margaret said. "This works great for trim or upperCase because they only take one argument. But what if I want to use a standard two-argument addition function?"

He typed out an example:

// A standard function
const standardAdd = (a, b) => a + b;
const double = x => x * 2;

// This won't work in a pipe! 
const newNumber = pipe(
    double,
    standardAdd // Problem: expect (a, b), but pipe only gives it 'a'
)(5); 

console.log(newNumber); // NaN (because 'b' was undefined!)

Enter fullscreen mode Exit fullscreen mode

Margaret drew another quick sketch to show the problem. "The pipe is a standard connector. It expects every segment to have exactly one input and one output."

The Problem: A mismatched pipe connection

[Previous Func] outputs: 5
       |
       V
(Pipe expects 1 input) -> +-------------------+
                          | standardAdd(a, b) | <--- standardAdd doesn't fit!
(Function needs 2!) ----> +-------------------+

Enter fullscreen mode Exit fullscreen mode

"And now you know why I made you learn Currying last week."

"It's an adapter?" Timothy asked.

"Precisely!" Margaret said. "Currying allows us to pre-fill arguments until the function only needs one more thing—the thing coming down the pipe."

The Solution: Currying as an Adapter

[Previous Func] outputs: 5
       |
       V
+-----------------+
|    add(10)      | <--- Curried version "pre-fills" one slot.
+-----------------+      Now it fits perfectly in the pipe.
       |
       V
Result: 15

Enter fullscreen mode Exit fullscreen mode

She fixed the code using the techniques from their previous lesson. "Let's define a curried version of add so it fits:"

// Defining a curried version of 'add'
const add = a => b => a + b; 
// We reuse 'double' from the previous example

// Now we can "pre-load" the add function
// add(10) returns a function that waits for the second number
const processNumber = pipe(
    double,    // 5 * 2 = 10
    add(10),   // 10 comes down the pipe. 10 + 10 = 20
    double     // 20 * 2 = 40
);

console.log(processNumber(5)); // 40

Enter fullscreen mode Exit fullscreen mode

Margaret's Cheat Sheet

Margaret finished her coffee and left a sticky note on Timothy's monitor.

When to use Composition (Pipes):

  • Data Transformation: When you have raw data (like a user object) that needs to go through multiple steps of "polishing" (sanitize -> validate -> normalize).
  • Readability: When you find yourself nesting functions more than 3 layers deep (f(g(h(x)))).
  • Refactoring: When you see a "God Function" doing ten things. Break it into ten small functions and pipe them together.

A Warning on Async:
"This simple pipe function is synchronous. If your functions return Promises (if they are async), the pipe will break because the next function will receive a Promise instead of a value."

Glossary

  • Unary Function: A function that takes exactly one argument. Ideally, all functions in a pipe should be unary.
  • Pure Function: A function that always returns the same output for the same input and causes no side effects (like changing global variables).
  • Currying: Transforming a function with multiple arguments into a chain of single-argument functions. It’s the "adapter" that lets complex functions fit into a pipe.

Aaron Rose is a software engineer and technology writer at tech-reader.blog and the author of Think Like a Genius.

Top comments (0)