JavaScript Higher-Order Functions and Composition: pipe() and compose()

Link copied
JavaScript Higher-Order Functions and Composition: pipe() and compose()

JavaScript Higher-Order Functions and Composition: pipe() and compose()

JavaScript's first-class functions — functions that can be passed as arguments, returned from other functions, and stored in variables — are the language's superpower. The patterns built on this property (map, filter, reduce, pipe, compose) turn a procedural language into something close to a functional one.

This is Part 17 of the JavaScript Fundamentals series. Part 16 on pure functions is the foundation — higher-order functions shine when their parts are pure.

What "higher-order" means #

A function is higher-order if it does either:

  1. Takes a function as an argument, or
  2. Returns a function.

Examples of (1):

const doubled = [1, 2, 3].map(n => n * 2);   // map takes a function
button.addEventListener('click', handler);    // addEventListener takes a function

Examples of (2):

const adder = (n) => (x) => x + n;            // adder returns a function
const add5 = adder(5);
add5(10);                                      // 15

Most utility libraries (lodash, ramda, rxjs) are made almost entirely of higher-order functions.

The big three: map, filter, reduce #

If you understand these three, you understand 90% of the array work you do daily. Each is pure (when given pure callbacks) and returns a new value without mutating the input.

map — transform each element #

[1, 2, 3].map(n => n * 2);     // [2, 4, 6]
users.map(u => u.name);        // ['Alice', 'Bob']

filter — keep elements that pass a test #

[1, 2, 3, 4].filter(n => n % 2 === 0);   // [2, 4]
users.filter(u => u.active);             // active users only

reduce — fold elements into a single value #

[1, 2, 3, 4].reduce((sum, n) => sum + n, 0);   // 10
users.reduce((map, u) => ({ ...map, [u.id]: u }), {}); // id-keyed lookup

Reduce is the most general — map and filter are both implementable as reduces. But the named operations are clearer when they fit.

Why use them over a for loop? #

// Imperative
const names = [];
for (let i = 0; i < users.length; i++) {
  if (users[i].active) names.push(users[i].name);
}

// Declarative
const names = users
  .filter(u => u.active)
  .map(u => u.name);

The declarative version reads top-to-bottom as a sentence: "users who are active, then their names." The imperative version requires reading 4 lines to discover the intent. Modern JS engines optimize both about equally; readability wins.

We have a whole post on JavaScript array iteration methods that covers the rest (find, some, every, flatMap, forEach, findIndex).

Function composition #

Composition = chaining functions so the output of one becomes the input of the next.

Mathematically: (f ∘ g)(x) = f(g(x)).

In JavaScript, two helpers do this:

pipe — left-to-right (the readable one) #

const pipe = (...fns) => (x) => fns.reduce((acc, fn) => fn(acc), x);

const slugify = pipe(
  (s) => s.trim(),
  (s) => s.toLowerCase(),
  (s) => s.replace(/[^a-z0-9]+/g, '-'),
  (s) => s.replace(/^-|-$/g, ''),
);

slugify('  Hello, World!  '); // 'hello-world'

Reads top-to-bottom in order: trim, lowercase, replace, trim hyphens. Each step takes the previous step's output.

compose — right-to-left (the math-purist one) #

const compose = (...fns) => (x) => fns.reduceRight((acc, fn) => fn(acc), x);

const slugify = compose(
  (s) => s.replace(/^-|-$/g, ''),
  (s) => s.replace(/[^a-z0-9]+/g, '-'),
  (s) => s.toLowerCase(),
  (s) => s.trim(),
);

Same behavior, but the functions are listed in the order f(g(h(x))) — which matches the math but reads inside-out. Most modern teams prefer pipe.

A worked example: API response shaping #

const shapeUsers = pipe(
  (raw)   => raw.data || [],
  (arr)   => arr.filter(u => !u.deleted),
  (arr)   => arr.map(u => ({ id: u.id, name: u.full_name, role: u.role || 'user' })),
  (arr)   => arr.sort((a, b) => a.name.localeCompare(b.name)),
  (arr)   => arr.slice(0, 20),
);

const response = await fetch('/users').then(r => r.json());
const users = shapeUsers(response);

Five small pure transforms. Each is independently testable. Replacing any step is a one-line edit. The pipeline itself is the documentation.

Partial application and currying #

A higher-order pattern: take a multi-argument function and produce a single-argument version with some args already filled in.

const partial = (fn, ...preset) => (...rest) => fn(...preset, ...rest);

const log = (level, msg) => console.log(`[${level}]`, msg);
const logError = partial(log, 'ERROR');
logError('Connection lost');  // [ERROR] Connection lost

Currying is the same idea taken further — a 3-argument function becomes 3 single-argument functions:

const curry = (fn) => (a) => (b) => (c) => fn(a, b, c);

const add = curry((a, b, c) => a + b + c);
add(1)(2)(3);  // 6
add(1)(2);     // a function awaiting c

Useful when piping — many functional libs use currying so you can write pipe(filter(isActive), map(toName)) instead of pipe(arr => arr.filter(isActive), arr => arr.map(toName)).

Full deep-dive: our post on currying.

Higher-order patterns in the wild #

Once you see them, you find higher-order functions everywhere:

Middleware #

// Express
app.use((req, res, next) => { /* do something */; next(); });

// Each middleware takes the next as a callback. Higher-order.

React HOCs #

const withAuth = (Component) => (props) =>
  isLoggedIn() ? <Component {...props} /> : <Login />;

A function that takes a component and returns a wrapped component. Pure HOF.

RxJS operators #

stream.pipe(
  map(x => x * 2),
  filter(x => x > 10),
  debounceTime(300),
)

Each operator is a higher-order function returning an operator that transforms the stream. We cover this kind of pipeline in our RxJS debouncing post.

Decorators (proposal) #

@logged
class Service {
  @memoized
  async fetch(id) { /* ... */ }
}

Decorators are higher-order functions wrapping classes and methods. Still TC39 stage 3 but adopted in many frameworks.

Common gotchas #

Gotcha 1: forEach is a higher-order function — but it returns undefined.

const result = [1, 2, 3].forEach(n => n * 2); // undefined

If you want a transform, use map. forEach exists for side effects.

Gotcha 2: reduce initial value is mandatory in practice.

[].reduce((a, b) => a + b);          // TypeError — empty array, no init
[].reduce((a, b) => a + b, 0);       // 0 — safe

Always pass the initial value unless you're sure the array is non-empty.

Gotcha 3: Loops written as reduce chains can be inscrutable.

arr.reduce((acc, x) => acc.concat([[x, x * 2]]), []); // hard to read

If a reduce is doing 3+ things, refactor to a for...of for clarity. Functional ≠ always better.

Gotcha 4: Composing async functions needs an async pipe.

const pipeAsync = (...fns) => (x) => fns.reduce((p, fn) => p.then(fn), Promise.resolve(x));

const processUser = pipeAsync(
  fetchUser,         // async
  enrichWithRoles,   // async
  formatForDisplay,  // sync ok
);

Sync pipe won't await. Use this variant for promise-returning steps.

Gotcha 5: Currying hurts readability when arities aren't fixed. If a function takes optional args, currying it produces something confusing. Curry only narrow, fixed-arity functions.

Recap #

  • A higher-order function takes a function as an argument or returns one. JavaScript's first-class functions make this natural.
  • The big threemap, filter, reduce — replace most for loops with declarative one-liners.
  • Composition chains functions: pipe(f, g, h)(x) = h(g(f(x))). pipe reads top-to-bottom; compose reads inside-out.
  • Partial application and currying produce specialized functions from general ones.
  • Higher-order patterns show up in middleware, React HOCs, RxJS operators, and decorators.
  • For async pipelines, use a pipeAsync variant.

Next up: ES2024+ Features Worth Using — the latest additions to the language (Object.groupBy, toSorted, Promise.withResolvers, and more), what's worth adopting today, and what's still proposal-stage.

Up next in JavaScript

More from this topic

View all JavaScript articles →

Enjoyed this article?

Get new JavaScript tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email address will not be published. Required fields are marked *