Learn JS Series (#18) - Higher-Order Functions: Functions That Take or Return Functions
Learn JS Series (#18) - Higher-Order Functions: Functions That Take or Return Functions
What will I learn
- You will learn the precise definition of a higher-order function, and why the idea matters far more than the fancy name;
- how
map,filter, andreduceare higher-order functions you already reach for every day; - how to write your own higher-order functions that accept behaviour as a parameter;
- how to build function decorators that wrap and enhance other functions without touching their code;
- how to stack decorators in layers, each one small and reusable;
- why this style makes code more expressive and less bug-prone than copy-pasting loops.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Node.js (20+) distribution, or just a modern browser console;
- Episodes 1-17 read, especially first-class functions (ep15) and closures (ep17).
Difficulty
- Beginner
Curriculum (of the Learn JS Series):
- Learn JS Series (#1) - What Is JavaScript, Why It Runs Everywhere, and How to Run It
- Learn JS Series (#2) - Variables and Bindings
- Learn JS Series (#3) - The Primitive Types: number, string, boolean, null, undefined, symbol, bigint
- Learn JS Series (#4) - Operators and Expressions: Arithmetic, Comparison, Logical, and Short-Circuiting
- Learn JS Series (#5) - Strings: Template Literals, Unicode, and the Methods You Actually Use
- Learn JS Series (#6) - Numbers: IEEE 754, Why 0.1 + 0.2 Is Not 0.3, and How to Cope
- Learn JS Series (#7) - Control Flow: if/else, switch, and the Ternary Expression
- Learn JS Series (#8) - Loops: for, while, for...of, for...in, and When to Use Which
- Learn JS Series (#9) - Functions: Declarations, Parameters, Return Values, and Hoisting
- Learn JS Series (#10) - Scope and the Temporal Dead Zone: How JavaScript Finds Your Variables
- Learn JS Series (#11) - Arrays: The Workhorse Data Structure and Its Core Methods
- Learn JS Series (#12) - Objects: Key-Value Data, Dot vs Bracket Access, and Nesting
- Learn JS Series (#13) - Truthiness, Equality, and Coercion: == vs === Done Properly
- Learn JS Series (#14) - Mini Project: A Command-Line Tip Calculator
- Learn JS Series (#15) - First-Class Functions: Passing, Returning, and Storing Functions
- Learn JS Series (#16) - Arrow Functions vs function: Syntax, this, and When Each Wins
- Learn JS Series (#17) - Closures: The Single Most Important Idea in JavaScript
- Learn JS Series (#18) - Higher-Order Functions: Functions That Take or Return Functions (this post)
Learn JS Series (#18) - Higher-Order Functions: Functions That Take or Return Functions
Solutions to Episode 17 Exercises
Exercise 1 - a bank account with a private balance:
function makeBankAccount(initial) {
let balance = initial; // private, closed over
return {
deposit(amount) { balance += amount; return balance; },
withdraw(amount) { balance -= amount; return balance; },
balance() { return balance; },
};
}
const acc = makeBankAccount(100);
console.log(acc.deposit(50)); // 150
console.log(acc.withdraw(30)); // 120
// there is no way to touch 'balance' directly from out here
The insight: balance lives only in the closure, so the three methods are the only door to it, which is real encapsulation with zero class syntax.
Exercise 2 - the loop bug and its one-keyword fix:
const withVar = [];
for (var i = 0; i < 3; i++) withVar.push(() => i);
console.log(withVar.map((f) => f())); // [3, 3, 3]
const withLet = [];
for (let j = 0; j < 3; j++) withLet.push(() => j);
console.log(withLet.map((f) => f())); // [0, 1, 2]
The insight: changing var to let gives each iteration its own fresh binding, so each closure captures a different value instead of all sharing the one i.
Exercise 3 - a once-only email sender:
function once(fn) {
let called = false;
return (...args) => {
if (called) return;
called = true;
return fn(...args);
};
}
const send = once(() => console.log("sending welcome email"));
send(); send(); send(); // logs exactly once
The insight: the called flag in the closure remembers that the work already ran, so every later call is a silent no-op.
That third exercise is the one to keep in mind for today, because once is not just a closure trick -- it is our first real higher-order function, and we are about to name the whole family it belongs to.
Learn JS Series (#18) - Higher-Order Functions: Functions That Take or Return Functions
What a higher-order function is
A higher-order function is simply a function that does at least one of two things: it takes a function as an argument, or it returns a function (or both). That is the entire definition. The name sounds heavy -- it comes from mathematics, where a "higher-order" operation works on other operations rather than on plain values -- but the idea is light. You have already written several of these: makeCounter, once, memoize, makeFormatter, every factory from the last two episodes, and every single time you called .map().
The reason this deserves its own episode is not the definition, it is the style the definition unlocks. Higher-order functions let you factor out the part of an algorithm that varies and pass it in, so the reusable part -- the "how" -- lives in exactly one place. Everything below is a variation on that single move. And because a function you pass in usually closes over its surroundings (episode 17), higher-order functions and closures are really two halves of one tool. Keep that pairing in mind; it is what makes the whole thing so expressive.
You already use them: map, filter, reduce
The array methods from episode 11 are the higher-order functions you meet first, usually without noticing the category. They each take a function and call it for you. Look at what they have in common: a fixed traversal algorithm, with the per-element behaviour handed in from outside:
const nums = [1, 2, 3, 4, 5];
console.log(nums.map((n) => n * n)); // [1, 4, 9, 16, 25]
console.log(nums.filter((n) => n % 2)); // [1, 3, 5]
console.log(nums.reduce((s, n) => s + n, 0)); // 15
map owns the loop-and-collect logic; you own the transform. filter owns the loop-and-keep logic; you own the test. reduce owns the loop-and-accumulate logic; you own how two values combine. That separation -- generic mechanism plus injected behaviour -- is higher-order programming, and once you see it here you will start seeing it everywhere.
To prove there is no magic, here is map written by hand. It is a completely ordinary function that happens to take another function as a parameter:
function myMap(array, transform) {
const out = [];
for (let i = 0; i < array.length; i++) {
out.push(transform(array[i], i)); // call the injected behaviour
}
return out;
}
console.log(myMap([1, 2, 3], (n) => n * 10)); // [10, 20, 30]
There it is. No cleverness, no special syntax, just a loop that calls a function you handed it. The built-in map does a bit more (it skips holes in sparse arrays, passes the array as a third argument), but the shape is exactly this. If you understand myMap, you understand the entire family.
Writing your own that take a function
Let's write a few higher-order functions of our own, to really feel how they factor code. Suppose you often need to run something a fixed number of times, collecting each result. Instead of copying a for loop into ten different places, write the loop once and take the body as a parameter:
function times(n, action) {
const results = [];
for (let i = 0; i < n; i++) {
results.push(action(i));
}
return results;
}
console.log(times(4, (i) => i * 10)); // [0, 10, 20, 30]
console.log(times(3, () => "hi")); // ["hi", "hi", "hi"]
times knows how to loop; the caller supplies what to do on each pass. The looping code exists once, tested once, and every caller borrows it. Here is another one that would be genuinely painful to copy-paste correctly, but is trivial as a higher-order function -- a generic "try this risky thing, and fall back if it throws":
function attempt(fn, fallback) {
try {
return fn();
} catch {
return fallback;
}
}
console.log(attempt(() => JSON.parse("{ bad json"), null)); // null, no crash
console.log(attempt(() => JSON.parse('{"ok":1}'), null)); // { ok: 1 }
attempt wraps any risky function with the same safety logic. Notice the pattern: the try/catch is the fixed "how", and the specific risky operation is the varying part passed in. Write the guard once, use it around anything. That is the payoff, and it scales -- the more places you would have duplicated the pattern, the more attempt earns its keep.
One thing worth pointing out: attempt takes a function, () => JSON.parse(...), not the result of JSON.parse. If we passed the result, the parse would blow up before attempt ever ran -- there would be nothing left to guard. Wrapping the risky work in an arrow is what lets attempt decide when (and whether) to run it. That "pass the recipe, not the meal" habit is central to higher-order programming, and we lean on it heavily when we reach callbacks and Promises.
Returning functions: decorators
The other flavour -- returning a function -- is where things get really expressive. Returning a function lets you enhance an existing function by wrapping it in new behaviour. A wrapper that adds behaviour around another function is traditionally called a decorator. A logging decorator, for instance, wraps any function so that every call is traced to the console:
function withLogging(fn, name) {
return (...args) => {
console.log(`calling ${name} with`, args);
const result = fn(...args);
console.log(`${name} returned`, result);
return result;
};
}
const add = (a, b) => a + b;
const loggedAdd = withLogging(add, "add");
loggedAdd(2, 3);
// "calling add with [ 2, 3 ]"
// "add returned 5"
Read what happened carefully, because it is the heart of the episode. withLogging did not change add. It returned a brand new function that calls add in the middle of some extra behaviour. The original add is untouched and still usable on its own. The ...args (rest parameters, which we cover properly in the next couple of episodes) let the wrapper accept any arguments and forward them unchanged, so the decorator works on functions of any shape.
This is closures and higher-order functions working together, exactly as promised: withLogging returns a function (higher-order), and that returned function closes over fn and name (closure). Neither idea alone gets you here; together they give you a general way to add timing, caching, retries, validation, or access checks to functions without editing their source. That is a big deal in real codebases, where you often cannot or should not touch the function you want to instrument.
A timing decorator
To make the power concrete, here is a decorator that measures how long a function takes -- useful the moment you start caring about performance (a subject we treat seriously in Phase 14):
function withTiming(fn, name) {
return (...args) => {
const start = Date.now();
const result = fn(...args);
const ms = Date.now() - start;
console.log(`${name} took ${ms}ms`);
return result;
};
}
const slow = (n) => {
let sum = 0;
for (let i = 0; i < n; i++) sum += i;
return sum;
};
const timedSlow = withTiming(slow, "sum");
console.log(timedSlow(1_000_000)); // logs the elapsed time, then the result
You can wrap any function with withTiming and instantly get timing, without touching the function itself. The pattern is identical to withLogging -- take a function, return an enhanced function -- which is the whole point: once you recognise the shape, you can produce a new decorator in about thirty seconds.
A retry decorator, and stacking them
Here is a slightly meatier one, because it shows the pattern paying real rent. withRetry wraps a flaky function so it is re-attempted a few times before giving up:
function withRetry(fn, attempts) {
return (...args) => {
let lastError;
for (let i = 0; i < attempts; i++) {
try {
return fn(...args);
} catch (err) {
lastError = err;
}
}
throw lastError; // all attempts failed
};
}
let calls = 0;
const flaky = () => {
calls += 1;
if (calls < 3) throw new Error("temporary glitch");
return "success on attempt " + calls;
};
console.log(withRetry(flaky, 5)()); // "success on attempt 3"
Now the real reason decorators are beautiful: because a decorator takes a function and returns a function of the same shape, you can stack them. The output of one is a perfectly good input to the next. Watch us wrap a function in retry, then logging, then timing, each layer added independently:
const risky = (x) => {
if (x < 0) throw new Error("negative!");
return x * 2;
};
// wrap in layers: timing on the outside, then logging, then retry closest to risky
const wrapped = withTiming(withLogging(withRetry(risky, 3), "risky"), "risky");
console.log(wrapped(21)); // retries if needed, logs the call, times it, returns 42
Each decorator is a small, independent, testable higher-order function, and you compose them like building blocks. That layered style -- small wrappers combined into bigger behaviour -- is the seed of the functional programming we explore fully in Phase 10. The order matters (the innermost wrapper runs closest to risky), which is a nice thing to reason about once, and then it just works.
Functions that build functions
Returning a function is not only for decorating an existing one. Sometimes you use it to manufacture a family of related functions from a single template. We saw makeFormatter last episode; here is a between factory that bakes in a range and hands you back a tailored predicate, ready to drop straight into filter:
function between(min, max) {
return (n) => n >= min && n <= max;
}
const isTeen = between(13, 19);
const isWorkHour = between(9, 17);
console.log([10, 15, 21, 13].filter(isTeen)); // [15, 13]
console.log(isWorkHour(14)); // true
between is a higher-order function (it returns a function) and isTeen is an ordinary predicate that happens to have been born with min and max already closed over. This is how you build small, named, reusable pieces instead of scattering n >= 13 && n <= 19 across your code by hand. Name the idea once, reuse the name forever.
Why this beats copy-pasting
Step back and appreciate what higher-order functions actually buy you. The alternative to times, attempt, withLogging, withTiming, and withRetry is copying the same loop, the same try/catch, the same timing lines into every place you happen to need them. That duplication is precisely where bugs breed: you fix the pattern in one copy and quietly forget the other nine, and now two of your ten call sites behave differently and nobody knows why. Higher-order functions let you write each pattern once, name it, test it in isolation, and reuse it with confidence.
Here is the whole toolkit composing into one small, honest example -- a safe, logged parse built entirely out of the pieces above:
const parseNumber = (s) => {
const n = Number(s);
if (Number.isNaN(n)) throw new Error("not a number");
return n;
};
const safeParse = withLogging((s) => attempt(() => parseNumber(s), 0), "parse");
console.log(safeParse("42")); // logs, returns 42
console.log(safeParse("oops")); // logs, returns 0 (the fallback)
No new machinery -- just withLogging and attempt, each written once, clicking together like Lego. Having said that, do not go decorator-crazy: a single plain function is often clearer than a tower of wrappers, and the goal is always readability, not cleverness for its own sake. ;-)
How other languages handle this
Many of you arrived from the Learn Python Series (and a few from Rust and Go), so a quick look sideways helps place this concept. The reassuring news: higher-order functions are not a JavaScript quirk. They are a broadly shared, deeply useful idea, and seeing the same shape in other languages makes it stick.
Python treats functions as first-class values too, so it has the exact same family. It even ships map and filter as built-ins, and a functools.reduce, plus the ability to return functions from functions (Python calls the returned-function-that-remembers-state a closure, same as we do):
def between(lo, hi):
return lambda n: lo <= n <= hi # returns a function
is_teen = between(13, 19)
print(list(filter(is_teen, [10, 15, 21, 13]))) # [15, 13]
print(list(map(lambda n: n * n, [1, 2, 3]))) # [1, 4, 9]
Same idea, slightly different spelling -- and note Python's decorators (the @something syntax you may have seen above a def) are literally this episode formalised into a language feature: a function that takes a function and returns an enhanced one. Our withLogging is a Python decorator written by hand.
Rust has closures with a |params| body syntax, and its iterator methods are higher-order to the core -- map, filter, fold (Rust's reduce) all take a closure. The difference, as always with Rust, is that the types are explicit and checked at compile time:
fn main() {
let nums = vec![1, 2, 3, 4, 5];
let squares: Vec<i32> = nums.iter().map(|n| n * n).collect();
let odds: Vec<&i32> = nums.iter().filter(|n| *n % 2 == 1).collect();
println!("{:?} {:?}", squares, odds); // [1, 4, 9, 16, 25] [1, 3, 5]
}
Same map/filter shapes we started with, just with the compiler double-checking that your closure fits. Rust makes you say more up front and rewards you with guarantees; JavaScript stays loose and lets you move fast. That trade-off is the recurring theme every time we hold these languages up side by side.
Go uses function values that you pass around explicitly. It has no built-in generic map, so people often write their own higher-order helpers -- which is a lovely illustration that the concept is just "a function taking a function", nothing more exotic:
package main
import "fmt"
func mapInts(xs []int, f func(int) int) []int {
out := make([]int, len(xs))
for i, x := range xs {
out[i] = f(x)
}
return out
}
func main() {
fmt.Println(mapInts([]int{1, 2, 3}, func(n int) int { return n * n })) // [1 4 9]
}
That mapInts is our myMap from earlier, wearing Go clothes. The throughline across all four languages: a function can accept or produce another function, and that one capability lets you factor behaviour instead of duplicating it. JavaScript's version is unusually central because so much of the language and its ecosystem -- array methods, event handlers, Promises, the whole async story -- is built on passing functions around.
Try it yourself
Three exercises, increasing in difficulty. Type them out and run them -- reading really is not the same as knowing. Full solutions open the next episode.
- Write a higher-order function
mapObject(obj, fn)that returns a new object with the same keys but each value transformed byfn. Test it by doubling every value in{ a: 1, b: 2, c: 3 }. (Hint:Object.entries, a loop ormap, and building a new object.) - Write a decorator
withCount(fn)that returns a wrapped function which also tracks how many times it has been called, and exposes that count somehow (a property on the returned function is one clean way). (Hint: a closure counter, likeoncebut incrementing instead of latching.) - Write a higher-order function
filterAndTransform(array, keep, transform)that keeps the elements passingkeep, then appliestransformto each survivor, in a single function. Test it on numbers: keep the evens, then square them. Bonus: implement it usingfilterandmapinternally, then again with a singlereduce, and convince yourself both give the same answer.
So what did we actually cover?
- A higher-order function takes a function as an argument, returns a function, or both -- that is the whole definition, borrowed from maths but light in practice.
map,filter, andreduceare higher-order functions: they own the traversal, you inject the per-element behaviour. WritingmyMapby hand shows there is no magic.- Writing your own (
times,attempt) lets you factor a pattern once and reuse it, instead of copy-pasting loops andtry/catchblocks that later drift out of sync. - Returning a function creates decorators (
withLogging,withTiming,withRetry) that wrap and enhance other functions without editing them -- and because they preserve the shape, you can stack them in layers. - Factories like
betweenmanufacture whole families of small, named, reusable functions from one template. - Higher-order functions plus closures are how JavaScript expresses reusable behaviour, and Python, Rust, and Go all share the same core idea with different amounts of ceremony.
Next episode we zoom in on one specific, hugely important use of passing a function as an argument: the callback pattern, where the function you hand over runs later, when some work finishes. That is the stepping stone to Promises and the entire async story waiting for us in Phase 6.