Higher-Order Functions
A higher-order function takes another function as an argument or returns a function. They are the building blocks of clean, reusable JavaScript.
HOFs accept or return functions. setTimeout, map, filter are all HOFs. They let you extract changing logic into callbacks while reusing the loop structure (DRY principle).
EXERCISE
1What Is a Higher-Order Function?
A higher-order function is any function that takes another function as an argument or returns a function. You have already used them without knowing it.
A higher-order function is one that takes a function as an argument, returns a function, or both. That is the entire definition, and you have already written both kinds in this unit. The name sounds academic; the thing itself is ordinary and you use it constantly.
const numbers = [1, 2, 3, 4, 5];
const doubled = numbers.map(function (n) {
return n * 2;
});
const big = numbers.filter(function (n) {
return n > 3;
});
console.log(doubled); // [2, 4, 6, 8, 10]
console.log(big); // [4, 5]
map and filter are higher-order functions. So is setTimeout, so is addEventListener, and so is sort. Each one handles a fixed piece of machinery and takes the part that varies as a function.
Writing one is no harder than using one:
function applyToAll(items, fn) {
const result = [];
for (let i = 0; i < items.length; i++) {
result.push(fn(items[i]));
}
return result;
}
const prices = [10, 20, 30];
console.log(applyToAll(prices, (p) => p * 0.9)); // [9, 18, 27]
console.log(applyToAll(prices, (p) => p + 5)); // [15, 25, 35]
applyToAll is map, written out. It owns the loop, the index and the accumulating array, and it knows nothing about discounts or addition. Those arrive as the second argument.
The separation is what makes the function reusable. Compare it with the version where the operation is baked in:
function discountAll(items) {
const result = [];
for (let i = 0; i < items.length; i++) {
result.push(items[i] * 0.9);
}
return result;
}
This works, and it answers exactly one question. Every new operation needs another copy of the same five lines, differing only in the middle one. The higher-order version answers all of them.
Pro Tip: When you notice two functions that differ by a single line, the differing line is usually a parameter waiting to be extracted. That is the whole technique, and it is why higher-order functions show up wherever the same shape of work repeats.
The same extraction works on the condition rather than the transformation, which is filter:
function keepIf(items, test) {
const result = [];
for (let i = 0; i < items.length; i++) {
if (test(items[i])) result.push(items[i]);
}
return result;
}
const ages = [12, 19, 34, 7];
console.log(keepIf(ages, (a) => a >= 18)); // [19, 34]
Structurally identical to the previous function. The only difference is whether the supplied function decides what an element becomes or whether it stays.
EXERCISE
2The DRY Principle: Why Higher-Order Functions Matter
DRY stands for Do Not Repeat Yourself. Higher-order functions let you extract the thing that changes (the logic) and reuse the thing that stays the same (the loop structure).
The cost of not using a higher-order function is duplicated structure. Three loops that differ by a single line still have to be written three times, read three times, and corrected three times when the shared part turns out to be wrong. The duplication is not the typing; it is that a fix applied in two places out of three leaves a bug that looks like it was already handled.
const radii = [1, 2, 3, 4];
const areas = [];
for (let i = 0; i < radii.length; i++) {
areas.push(Math.PI * radii[i] * radii[i]);
}
const circumferences = [];
for (let i = 0; i < radii.length; i++) {
circumferences.push(2 * Math.PI * radii[i]);
}
const diameters = [];
for (let i = 0; i < radii.length; i++) {
diameters.push(2 * radii[i]);
}
Fifteen lines, of which three carry any meaning. The other twelve are the same loop written out three times. If radii later needs filtering for invalid values, that change has to land in three places.
With the shape extracted, only the formulas remain:
const radii = [1, 2, 3, 4];
const areas = radii.map((r) => Math.PI * r * r);
const circumferences = radii.map((r) => 2 * Math.PI * r);
const diameters = radii.map((r) => 2 * r);
Three lines, each of which is the formula and nothing else. This is the DRY principle in its useful form: not "never type the same characters twice", but "every piece of knowledge should have one place to live".
The point is easier to see when the shared part has to change:
const valid = radii.filter((r) => r > 0);
const areas = valid.map((r) => Math.PI * r * r);
const circumferences = valid.map((r) => 2 * Math.PI * r);
const diameters = valid.map((r) => 2 * r);
One new line, applied once, and all three results are now filtered. In the loop version that same change is three edits, and the bug you get from making two of them is a set of arrays that disagree with each other about which radii exist.
The same argument applies to the callbacks themselves. If two of the three formulas needed rounding, the rounding belongs in one place rather than two:
const round2 = (n) => Math.round(n * 100) / 100;
const areas = radii.map((r) => round2(Math.PI * r * r));
const circumferences = radii.map((r) => round2(2 * Math.PI * r));
round2 is now the single definition of what rounding means here. Changing it to three decimal places is one edit, and there is no version of the code where one array is rounded differently from another.
EXERCISE
3Building Your Own Polyfill of map
A polyfill is a custom implementation of a built-in function. Building your own map teaches you exactly how higher-order functions work internally.
Higher-order functions compose. Because map and filter each return a new array, the result of one can be the input to the next, and a sequence of small transformations reads as a description of the work.
const orders = [
{ item: "book", price: 12, shipped: true },
{ item: "pen", price: 2, shipped: false },
{ item: "lamp", price: 40, shipped: true },
];
const shippedTotals = orders
.filter((o) => o.shipped)
.map((o) => o.price);
console.log(shippedTotals); // [12, 40]
Read top to bottom: keep the shipped orders, then take each price. Each step does one thing and hands its result to the next.
The original array is never modified:
console.log(orders.length); // 3
filter and map both build new arrays. That is what makes chaining safe: no step can disturb the data an earlier step produced, and nothing else holding a reference to orders is affected.
This is the difference from the loop-based equivalent, which usually mutates as it goes:
const totals = [];
for (let i = 0; i < orders.length; i++) {
if (orders[i].shipped) {
totals.push(orders[i].price);
}
}
The behaviour is the same. What differs is that the loop mixes the filtering, the extracting and the accumulating into one block, so you have to read all of it to know what any of it does.
Chains do have a cost worth knowing:
const result = orders
.filter((o) => o.shipped)
.map((o) => o.price);
Each step walks the whole array, so this makes two passes where the loop made one. For the sizes most code deals with that is irrelevant and the clarity is worth far more. For a very large array in a hot path it is worth knowing you made that trade, rather than discovering it later.
Pro Tip: Prefer the chain until you have measured a reason not to. Readable code that is slightly slower is easy to optimise once you know where the time goes; fast code nobody can follow is expensive to change at all.
One caution about chaining: every step must return something, or the next step receives undefined.
const prices = orders.map((o) => {
o.price * 2;
});
console.log(prices); // [undefined, undefined, undefined]
The braces made the body a block, and the block has no return. Removing the braces, or adding return, fixes it. This is the same implicit-return trap from earlier in the unit, and it is quietest inside a chain, where the empty result only causes a failure at the end.