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:
- Takes a function as an argument, or
- 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 three —
map,filter,reduce— replace mostforloops with declarative one-liners. - Composition chains functions:
pipe(f, g, h)(x)=h(g(f(x))).pipereads top-to-bottom;composereads 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
pipeAsyncvariant.
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
Enjoyed this article?
Get new JavaScript tutorials delivered. No spam — just code-first articles when they ship.


