Originally published 2 July 2019 at: https://jrsinclair.com/articles/2019/what-is-a-higher-order-function-and-why-should-anyone-care/
“Higher-order function” is one of those phrases people throw around a lot. But it's rare for anyone to stop to explain what that means. Perhaps you already know what a higher-order function is. But how do we use them in the real world? What are some practical examples of when and how they're useful? Can we use them for manipulating the DOM? Or, are people who use higher-order functions showing off? Are they over-complicating code for no good reason?
I happen to think higher order functions are useful. In fact, I think they are one of the most important features of JavaScript as a language. But before we get on to that, let's start by breaking down what a higher-order function is. To get there, we start with functions as variables.
Functions as first-class citizens.
In JavaScript, we have at least three different ways of writing a new function. (There's more than three ways to write a function, but we can talk about that another time.) First, we can write a function declaration. For example:
// Take a DOM element and wrap it in a list item element.
function itemise(el) {
const li = document.createElement('li');
li.appendChild(el);
return li;
}
I hope that's familiar. But, you probably know we could also write it as a function expression. That might look like so:
const itemise = function(el) {
const li = document.createElement('li');
li.appendChild(el);
return li;
}
And then, there's yet another way to write the same function: As an arrow function:
const itemise = (el) => {
const li = document.createElement('li');
li.appendChild(el);
return li;
}
For our purposes, all three functions are essentially the same. But notice that the last two examples assign the function to a variable. It seems like such a small thing. Why not assign a function to a variable? But this is a Big Deal. Functions in JavaScript are 'first class'. That is, we can:
- Assign functions to variables;
- Pass functions as arguments to other functions; and
- Return functions from other functions.
That's nice, but what does this have to do with higher-order functions? Well, pay attention to those last two points. We'll come back to them in a moment. Meanwhile, let's look at some examples.
We've seen assigning functions to variables. What about passing them as parameters though? Let's write a function that we can use with DOM elements. If we run document.querySelectorAll()
we get back a NodeList
rather than an array. NodeList
doesn't have a .map()
method like arrays do, so let's write one:
// Apply a given function to every item in a NodeList and return an array.
function elListMap(transform, list) {
// list might be a NodeList, which doesn't have .map(), so we convert
// it to an array.
return [...list].map(transform);
}
// Grab all the spans on the page with the class 'for-listing'.
const mySpans = document.querySelectorAll('span.for-listing');
// Wrap each one inside an <li> element. We re-use the
// itemise() function from earlier.
const wrappedList = elListMap(itemise, mySpans);
In this example, we pass our itemise
function as an argument to the elListMap
function. But we can use our elListMap
function for more than creating lists. For example, we might use it to add a class to a set of elements.
function addSpinnerClass(el) {
el.classList.add('spinner');
return el;
}
// Find all the buttons with class 'loader'
const loadButtons = document.querySelectorAll('button.loader');
// Add the spinner class to all the buttons we found.
elListMap(addSpinnerClass, loadButtons);
Our elLlistMap
function takes a function as a parameter, transform
. This means we can re-use the elListMap
function to do a bunch of different tasks.
We've now seen an example of passing functions as parameters. But what about returning a function from a function? What might that look like?
Let's start by writing a regular old function. We want to take a list of <li>
elements and wrap them in a <ul>
. Not so difficult:
function wrapWithUl(children) {
const ul = document.createElement('ul');
return [...children].reduce((listEl, child) => {
listEl.appendChild(child);
return listEl;
}, ul);
}
But what about if we later on have a bunch of paragraph elements we want to wrap in a <div>
? No problem. We write a function for that too:
function wrapWithDiv(children) {
const div = document.createElement('div');
return [...children].reduce((divEl, child) => {
divEl.appendChild(child);
return divEl;
}, div);
}
This will work fine. But those two functions are looking mighty similar. The only meaningful thing that changes between the two is the parent element we create.
Now, we could write a function that takes two parameters: the type of parent element, and the list of children. But, there is another way to do it. We could create a function that returns a function. It might look something like this:
function createListWrapperFunction(elementType) {
// Straight away, we return a function.
return function wrap(children) {
// Inside our wrap function, we can 'see' the elementType parameter.
const parent = document.createElement(elementType);
return [...children].reduce((parentEl, child) => {
parentEl.appendChild(child);
return parentEl;
}, parent);
}
}
Now, that may look a little complicated at first, so let's break it down. We've created a function that does nothing but return another function. But, the returned function remembers the elementType
parameter. Then, later, when we call the returned function, it knows what kind of element to create. So, we could create wrapWithUl
and wrapWithDiv
like so:
const wrapWithUl = createListWrapperFunction('ul');
// Our wrapWithUl() function now 'remembers' that it creates a ul element.
const wrapWithDiv = createListWreapperFunction('div');
// Our wrapWithDiv() function now 'remembers' that it creates a div element.
This business where the returned function 'remembers' something has a technical name. We call it a closure. Closures are excessively handy, but we won't worry too much about them right now.
So, we've seen:
- Assigning a function to a variable;
- Passing a function as a parameter; and
- Returning a function from another function.
All in all, having first-class functions seems pretty good. But what does this have to do with higher-order functions? Well, let's see the definition of higher-order function.
What is a higher-order function?
A higher-order function is:
A function that takes a function as an argument, or returns a function as a result
— Higher Order Function (2014), http://wiki.c2.com/?HigherOrderFunction.
Sound familiar? In JavaScript, functions are first-class citizens. The phrase 'higher-order functions' describes functions which take advantage of this. There's not much to it. It's a fancy-sounding phrase for a simple concept.
Examples of higher-order functions
Once you start looking though, you'll see higher-order functions all over the place. The most common are functions that accept functions as parameters. So we'll look at those first. Then we'll go through some practical examples of functions that return functions.
Functions that accept functions as parameters
Anywhere you pass a 'callback' function, you are using higher-order functions. These are everywhere in front-end development. One of the most common is the .addEventListener()
method. We use this when we want to make actions happen in response to events. For example, if I want to make a button pop up an alert:
function showAlert() {
alert('Fallacies do not cease to be fallacies because they become fashions');
}
document.body.innerHTML += `<button type="button" class="js-alertbtn">
Show alert
</button>`;
const btn = document.querySelector('.js-alertbtn');
btn.addEventListener('click', showAlert);
In this example, we create a function that shows an alert. Then we add a button to the page. And finally, we pass our showAlert()
function as an argument to btn.addEventListener()
.
We also see higher-order functions when we use array iteration methods. That is, methods like .map()
, .filter()
, and .reduce()
. We already saw this with our elListMap()
function:
function elListMap(transform, list) {
return [...list].map(transform);
}
Higher-order functions also help us deal with delays and timing. The setTimeout()
and setInterval()
functions both help us manage when functions execute. For example, if we wanted to remove a highlight class after 30 seconds, we might do something like this:
function removeHighlights() {
const highlightedElements = document.querySelectorAll('.highlighted');
elListMap(el => el.classList.remove('highlighted'), highlightedElements);
}
setTimeout(removeHighlights, 30000);
Again, we create a function and pass it to another function as an argument.
As you can see, we use functions that accept functions often in JavaScript. In fact, you probably use them already.
Functions that return functions
Functions that return functions aren't as common as functions that accept functions. But they're still useful. One of the most helpful examples is the maybe()
function. I've adapted this one from Reginald Braithewaite's JavaScript Allongé. It looks like this:
function maybe(fn)
return function _maybe(...args) {
// Note that the == is deliberate.
if ((args.length === 0) || args.some(a => (a == null)) {
return undefined;
}
return fn.apply(this, args);
}
}
Rather than decode how it works right now, let's look first at how we might use it. Let's examine our function elListMap()
again:
// Apply a given function to every item in a NodeList and return an array.
function elListMap(transform, list) {
// list might be a NodeList, which doesn't have .map(), so we convert
// it to an array.
return [...list].map(transform);
}
What happens if we pass a null
or undefined
value in to elListMap()
by accident? We get a TypeError
and whatever we wer doing comes crashing to a halt. The maybe()
function lets us fix that. We use it like this:
const safeElListMap = maybe(elListMap);
elListMap(x => x, null);
// ← undefined
Instead of everything crashing to a halt, the function returns undefined
. And if we were to pass that into another function protected by maybe()
… it would return undefined
again. And we can keep on using maybe()
to protect any number of functions we like. Much simpler than writing a bazillion if-statements.
Functions that return functions are also common in the React community. For example, connect()
from react-redux
is a function that returns a function.
So what?
We've seen some individual examples of what higher-order functions can do. But so what? What do they give us that we wouldn't have without them? Is there something bigger here than a handful of contrived examples?
To answer that question, let's examine one more example. Consider the built-in array method .sort()
. It has its problems, yes. It mutates the array instead of returning a new one. But let's ignore that for a second. The .sort()
method is a higher-order function. It takes a function as one of its parameters.
How does it work? Well, if we want to sort an array of numbers, we first create a comparison funtion. It might look something like this:
function compareNumbers(a, b) {
if (a === b) return 0;
if (a > b) return 1;
/* else */ return -1;
}
Then, to sort the array, we use it like so:
let nums = [7, 3, 1, 5, 8, 9, 6, 4, 2];
nums.sort(compareNumbers);
console.log(nums);
// 〕[1, 2, 3, 4, 5, 6, 7, 8, 9]
We can sort a list of numbers. But how useful is that? How often do we have a list of numbers that need sorting? Not so often. If I need to sort something, it's more often an array of objects. Something more like this:
let typeaheadMatches = [
{
keyword: 'bogey',
weight: 0.25,
matchedChars: ['bog'],
},
{
keyword: 'bog',
weight: 0.5,
matchedChars: ['bog'],
},
{
keyword: 'boggle',
weight: 0.3,
matchedChars: ['bog'],
},
{
keyword: 'bogey',
weight: 0.25,
matchedChars: ['bog'],
},
{
keyword: 'toboggan',
weight: 0.15,
matchedChars: ['bog'],
},
{
keyword: 'bag',
weight: 0.1,
matchedChars: ['b', 'g'],
}
];
Imagine we want to sort this array by the weight
of each entry. Well, we could write a new sorting function from scratch. But we don't need to. Instead, we create a new comparison function.
function compareTypeaheadResult(word1, word2) {
return -1 * compareNumbers(word1.weight, word2.weight);
}
typeaheadMatches.sort(compareTypeaheadResult);
console.log(typeaheadMatches);
// 〕[{keyword: "bog", weight: 0.5, matchedChars: ["bog"]}, … ]
We can write a comparison function for any kind of array we want. The .sort()
method makes a deal with us. It says: “If you can give me a comparison function, I will sort any array. Don't worry about what's in the array. If you give me a comparison function, I'll sort it.” So we don't have to worry about writing a sorting algorithm ourselves. We focus on the much more simple task of comparing two elements.
Now, imagine if we didn't have higher-order functions. We could not pass a function to the .sort()
method. We would have to write a new sorting function any time we needed to sort a different kind of array. Or, we'd end up re-inventing the same thing with function pointers or objects. Either way would be much clumsier.
We do have higher-order functions though. And this lets us separate the sorting function from the comparison function. Imagine if a clever browser engineer came along and updated .sort()
to use a faster algorithm. Everyone's code would benefit, regardless of what's inside the arrays they're sorting. And there's a whole collection of higher-order array functions that follow this pattern.
That brings us to the broader idea. The .sort()
method abstracts the task of sorting away from what's in the array. We have what we call a 'separation of concerns'. Higher-order functions let us create abstractions that would be clumsy or impossible otherwise. And creating abstractions is 80% of software engineering.
Whenever we refactor our code to remove repetition, we're createing abstractions. We see a pattern, and replace it with an abstract representation of that pattern. As a result, our code becomes more concise and easier to understand. At least, that's the idea.
Higher-order functions are a powerful tool for creating abstractions. And there's a whole field of mathematics related to abstractions. It's called Category Theory. To be more accurate, Category Theory is about finding abstractions of abstractions. To put it another way, it's about finding patterns of patterns. And over the last 70 years or so, clever programmers have been stealing their ideas. These ideas show up as programming language features and libraries. If we learn these patterns of patterns, we can sometimes remove whole swathes of code. Or reduce complex problems down to elegant combinations of simple building blocks. Those building block are higher-order functions. And this is why higher-order functions are important. Because with them, we have another powerful tool to fight complexity in our code.
If you'd like to learn more about higher-order functions, here's some references:
- Higher-Order Functions: Chapter 5 of Eloquent JavaScript by Marijn Haverbeke.
- Higher Order Functions: Part of the Composing Sofware series by Eric Elliott.
- Higher-Order Functions in JavaScript by M. David Green for Sitepoint.
You're probably using higher-order functions already. JavaScript makes it so easy for us that we don't think about them much. But it's handy to know what people are talking about when they throw around the phrase. It's not so complicated. But behind that one small idea, there's a lot of power.
Update 3 July 2019: If you're more experienced with functional programming, you may have noticed I've used impure functions and some… verbose function names. This isn't because I don't know about impure functions or general functional programming principles. And it's not how I would write function names in production code. This is an educational piece, so I've tried to choose practical examples that beginners will understand. Sometimes that means making compromises. I've written elsewhere about functional purity and general functional programming principles, for anyone who might be interested.
Free cheat sheet
If you found this article interesting, you might like the Civilised Guide to JavaScript Array Methods. It’s free for anyone who subscribes to receive updates: Acquire your copy.