Let's begin with a review on the definition of functions. Functions are just a piece of code as a value, which is then assigned to a variable.
So, theoretically, you can make a function return another function like this:
1function parent() {
2 return function child() {
3 return true;
4 };
5}
These types of functions are called higher-order functions, and they have some interesting characteristics.
Higher-order functions (HOF) are a type of functions that operate on other functions by either accepting them as input arguments or returning them as the output. Here is another example where one function takes another function as the input:
1function callback() {
2 console.log("This is a callback function.");
3}
4
5function higherOrder(func) {
6 console.log("This is a higher-order function.");
7 func();
8}
9
10higherOrder(callback);
1This is a higher-order function.
2This is a callback function.
In this case, higherOrder()
is a higher-order function, and the function that is passed as the input argument (callback()
) is a callback function. Pay attention to how we passed callback()
to higherOrder()
, and notice that the parentheses for callback()
is left out.
1higherOrder(callback);
This is because the entire function of callback()
is passed to higherOrder()
as a parameter. If you include the parentheses, that tells JavaScript to execute the function, and then pass the returned value to the higherOrder()
function.
In this case, the returned value would be undefined
.
Why do we need HOFs
So why do we need these functions?
Previously, we mentioned that functions provide a level of abstraction that hides the details underneath, allowing you to write your programs at a higher level. So, it only makes sense that one of the primary applications of higher-order functions is to encapsulate even more technical details.
In practice, you will often encounter situations where you have to create functions that are very similar to each other. For example, you are given the task to calculate the diameter and circumference of different circles. The radius of these circles are provided in an array.
1function calcDiameter(arr) {
2 let output = [];
3
4 for (let r of arr) {
5 output.push(2 * r);
6 }
7
8 return output;
9}
10
11function calcCircumference(arr) {
12 let output = [];
13
14 for (let r of arr) {
15 output.push(2 * Math.PI * r);
16 }
17
18 return output;
19}
The method output.push()
is used to append a new value to the output
array, and Math.PI
is one of JavaScript's built-in mathematical constants, which approximately equals to 3.1415926.
Notice that the functions calcDiameter()
and calcCircumference()
look very similar, except for how the diameter and circumference are calculated on line 5 and line 15.
We discussed that functions are used to reduce unnecessary repetitions in your code, but that doesn't seem to be working here. Luckily, you can still improve this code using a higher-order function.
First of all, instead of writing functions that complete the task on their own, you should make your functions more granular, making sure they are only doing one thing at a time.
For instance, in our example, the calcDiameter()
function iterates over the array and calculates the diameter based on each radius. Instead, you can create a diameter()
function that returns the diameter based on the input radius
.
1function diameter(radius) {
2 return 2 * radius;
3}
And another function, circumference()
, which returns the circumference based on the radius.
1function circumference(radius) {
2 return 2 * Math.PI * radius;
3}
Finally, you can create a higher-order function that accepts either one of these functions as an input argument (logic
). Inside the HOF, we are going to iterate over the array, and for each array element, logic()
will be used to perform calculations.
1function calculate(arr, logic) {
2 let output = [];
3
4 for (let r of arr) {
5 output.push(logic(r));
6 }
7
8 return output;
9}
10
11circles = [2, 10, 13, 7, 8, 6, 5];
12
13console.log(calculate(circles, diameter));
14console.log(calculate(circles, circumference));
1[
2 4, 20, 26, 14,
3 16, 12, 10
4]
5[
6 12.566370614359172,
7 62.83185307179586,
8 81.68140899333463,
9 43.982297150257104,
10 50.26548245743669,
11 37.69911184307752,
12 31.41592653589793
13]
As you can see, this solution is free of repetitive code and is much more flexible.
For example, if you need to calculate the area of the circles, all you need to do is create another function area()
that returns the area of a circle, and pass to the function calculate()
.
1function area(radius) {
2 return Math.PI * radius * radius;
3}
4
5console.log(calculate(circles, area));
Function factory
The previous example is one of the most common applications of higher-order functions.
The function calculate()
is acting as a container for smaller functions, such as diameter()
and area()
. By passing different logic
to calculate()
, you can use it to calculate different things.
However, there are some other applications of higher-order functions that are a bit more difficult to understand, but if you use these techniques well, they'll significantly improve the quality of your code.
For instance, the calculate()
example accepts a function as the input, but recall the definition of a higher-order function:
Higher-order function (HOF) is a type of functions that operate on other functions by either accepting them as input arguments, or returning them as the output.
Interesting things happen when you make a function return another function as the output.
The first example we are going to demonstrate is called a function factory. It is a function that returns new functions based on the input.
1function multiplier(mul) {
2 return function calc(number) {
3 number * mul;
4 };
5}
In practice, it is generally recommended to use the arrow function syntax here. It will make you code shorter and easier to read.
1function multiplier(mul) {
2 return (number) => number * mul;
3}
The function multiplier()
accepts an input argument mul
, and when it is executed, it will return the function:
1(number) => number * mul;
And this new function accepts an input argument number
, and returns number * mul
.
1function multiplier(mul) {
2 return (number) => number * mul;
3}
4
5let double = multiplier(2);
6
7let result = double(5);
8
9console.log(result);
In this example, let double = multiplier(2);
is the same as declaring the function:
1let double = function (number) {
2 return number * 2;
3};
Similarly, you can reuse the same function factory to make other functions.
1let double = multiplier(2);
2let triple = multiplier(3);
3let quadruple = multiplier(4);
Currying function
Currying is a programming technique that converts a complex function, which takes multiple inputs, into a series of functions that only accept one input. For example, here is a function that calculates the final price of a meal, including tax and tip.
This is where the arrow function syntax really shines.
1const calculateTotal = (baseCost) => (tax) => (tip) => {
2 const total = baseCost * (1 + tax / 100) * (1 + tip / 100);
3 return total.toFixed(2); // Round to 2 decimal places for cents
4};
5
6const applyTax = calculateTotal(25); // 25 is the base price
7const applyTip = applyTax(13); // 13% tax
8const totalPrice = applyTip(15); // 15% tip
9
10console.log(totalPrice); // -> 32.49
toFixed()
is a built-in method that exists for all numbers in JavaScript. It rounds the number to the specified decimal places.
The function calculateTotal()
takes three steps to execute. First, line 6 is equivalent to:
1const applyTax = (tax) => (tip) => {
2 const total = 25 * (1 + tax / 100) * (1 + tip / 100);
3 return total.toFixed(2); // Round to 2 decimal places for cents
4};
And line 7 is equivalent to:
1const applyTip = (tip) => {
2 const total = 25 * (1 + 13 / 100) * (1 + tip / 100);
3 return total.toFixed(2); // Round to 2 decimal places for cents
4};
Lastly, line 8 finishes the function by passing the final argument.
Yes, right now, it looks like a silly way to write this program, and it has only made things more complex than necessary. You could just calculate everything together, so why split an easy task into multiple steps?
That is because in a real-world scenario, it is possible that the required arguments aren't provided at the same time, and the curring technique would create functions that "remember" the previously provided arguments, making your functions more flexible under different use cases.
Wrapper function
A wrapper function is a function that modifies the behavior of other functions by adding additional features or functionalities.
For instance, logging is a very important step in the software development cycle. The program you write should keep a detailed record regarding the operation of the application.
However, adding logging functionality for everything you write tends to be a tedious job, and it is possible that you miss one or two functions, leaving potential security risks. Instead, you could create a function wrapper that automatically adds logging for whatever function that it wraps around.
1const withLog = (func) => {
2 return (...args) => {
3 console.log(`Calling function with arguments: ${args}`);
4 const result = func(...args);
5 console.log(`Function result: ${result}`);
6 return result;
7 };
8};
9
10const add = (a, b) => a + b;
11const wrappedAdd = withLog(add);
12
13const result = wrappedAdd(2, 3);
1Calling function with arguments: 2,3
2Function result: 5
The four HOFs demonstrated in this lesson cover the most common use cases, but you should note that there are many more smart applications of HOFs waiting for you to discover.