Functional Thinking
— Ancient Chinese proverb
Pure Functions
A function is called pure if its outputs (the returned values) depend only on its inputs and if the function doesn't have any side effects. This means that the function doesn't change program state and doesn't write anything to an external data source.
Here is an example of a pure function:
const square = (x) => x * 2;
Indeed, the output of square
depends only on its input and nothing else.
In addition, square
doesn't produce any side effects.
Here is an example of a function that is not pure:
const x = 2;
const addImpure = (y) => x + y;
The output of this function doesn't depend just on its input variables, but also on a global variable x
.
Here is another function that's not pure:
const sayHello = () => console.log('Hello, World!');
The sayHello
function has a side effect—it outputs something to the console.
Why do we care about all of this? The fundamental reason is that pure functions are very easy to reason about. There is practically no room for surprising behaviour.
Consider the above square
function.
It takes an input and produces an output that is dependent only on the input.
It doesn't matter what the rest of the program is doing—the function will always produce identical outputs for identical inputs.
If you are mathematically inclined, pure functions are basically regular mathematical functions. They take an input which is a member of some domain and produce an output which is a member of some codomain. For example, the
square
function is simply the function f: A → B, f(x) = x² where A and B are certain sets of numbers. Note that A and B are emphatically not equal to the set of real numbers since JavaScript can't represent every possible real number (we've already discussed this).
All of the above isn't true for the addImpure
function.
That's because it can produce different outputs for identical inputs.
This makes it very hard to troubleshoot the function in case of an error.
After all, you may not know what the (global) state of the program was when the error occurred.
Closely related is another very nice property of pure functions—they are easily testable. There is no need to fake global state as the function output depends only on the input. Therefore, all you need to do is to call the function, pass some input and check whether the output matches the expected output.
Immutability
A variable is immutable if it's unchangeable. Otherwise, we call it mutable. The more mutability we have inside our program the more can go wrong since it's hard to reason about (global) state.
This is where the alert reader might interject—after all, isn't the purpose of a program to do something? And how can we achieve that if we don't change state?
A fundamental correction is in order here—the purpose of every program isn't to do something, but to manipulate data. You can of course manipulate data directly by mutating global state like in the following example:
const task = {
id: 1,
title: 'Read the Next.js book',
description: 'Read and understand the Next.js book.',
};
task.title = 'Next.js book';
This works for simple objects and changes. But such an approach will quickly become brittle with growing complexity. Reasoning about state and state changes is hard.
Instead, we can create copies of the objects which contain the changes we need:
const newTask = {
...task,
title: 'Next.js book',
};
Note that we didn't change the original object, but created a copy of the object with a different title.
Immutability and pure functions are closely linked. The programs that are easiest to understand are the ones where immutable data structures are passed through pure functions.
Higher-Order Functions
We already know that JavaScript functions are just regular objects. We even showed an example of how you can assign a function to a variable:
const square = (num) => num * num;
This allows us to do interesting things. Because functions are just objects we can pass them to other functions.
Consider an example function that repeats some action n
times:
function repeat(fun, n) {
for (let i = 0; i < n; i++) {
fun();
}
}
We can use it like this:
const hello = () => console.log('Hello, World!');
repeat(hello, 4);
We could even shorten this code to:
repeat(() => console.log('Hello, World!'), 4);
Both versions will produce the same output:
Hello, World!
Hello, World!
Hello, World!
Hello, World!
The most important thing here is that the repeat
function doesn't care what fun
is.
Indeed, fun
could be a simple console.log
or a function which simulates a universe.
All the repeat
function does is simply repeat fun
the specified number of times.
Functions which take (or return) functions are called higher-order functions.
The Trinity of map
, filter
and reduce
We now introduce the three most important higher-order functions—map
, filter
and reduce
.
These functions allow you to perform an incredibly rich set of operations on arrays.
We want to use this blockquote to emphasize how often you will be using
map
,filter
andreduce
.
We will use two running examples throughout the section—an array of numbers
and an array of tasks
:
const numbers = [1, 2, 3, 4];
const tasks = [
{
id: 1,
title: 'Read the Next.js book',
description: 'Read and understand the Next.js book.',
timeLogged: 60,
status: 'In progress',
},
{
id: 2,
title: 'Write a task app',
description: 'Write an awesome task app.',
timeLogged: 0,
status: 'Todo',
},
{
id: 3,
title: 'Think of a funny joke',
description: 'Come up with a funny joke to lighten the mood.',
timeLogged: 120,
status: 'In progress',
},
];
The map
function takes one argument—a function f
to apply to every element of the array.
It returns the array that results from applying f
to every element of the original array.
Let's say we wanted to square all the elements of numbers
.
We could write something like this:
const result = [];
for (const number of numbers) {
result.push(number ** 2);
}
This is ugly and (you guessed it) tedious.
Instead we can (and should) use the map
function:
const result = numbers.map((number) => number ** 2);
console.log(result); // [1, 4, 9, 16]
Consider another example.
Let's say we wanted to add a long description to all the tasks
based on the title and the description.
We can use the map
function again:
const longTasks = tasks.map((task) => ({
...task,
longDescription: `${task.title}: ${task.description}`,
}));
You can see why the spread syntax is so handy. Thanks to this incredible innovation, you only need to explicitly specify the object parts where something interesting happens.
The longTasks
array will look like this:
[
{
id: 1,
title: 'Read the Next.js book',
description: 'Read and understand the Next.js book.',
timeLogged: 60,
status: 'In progress',
longDescription: 'Read the Next.js book: Read and understand the Next.js book.',
},
{
id: 2,
title: 'Write a task app',
description: 'Write an awesome task app.',
timeLogged: 0,
status: 'Todo',
longDescription: 'Write a task app: Write an awesome task app.',
},
{
id: 3,
title: 'Think of a funny joke',
description: 'Come up with a funny joke to lighten the mood.',
timeLogged: 120,
status: 'In progress',
longDescription: 'Think of a funny joke: Come up with a funny joke to lighten the mood.',
},
];
The filter
function allows you to select elements from an array based on some condition.
It takes a function f
which returns true
or false
for some input(s).
All elements for which f
returns true
are kept, all elements for which f
returns false
are thrown away.
A function which returns
true
orfalse
is commonly referred to as a predicate.
For example, let's say we want to select all even elements from numbers
.
Here is the non-functional way:
const result = [];
for (const number of numbers) {
if (number % 2 === 0) {
result.push(number ** 2);
}
}
Ugh! For loops and if statements all over the place. So non-functional. Let's rest our eyes and consider the functional approach:
const result = numbers.filter((number) => number % 2 === 0);
The filter
function also works in more complicated scenarios.
For example, we might want to select all tasks from the tasks
array which have the status 'Todo'
.
Think for a moment what the appropriate predicate would be.
That's right, it looks like this:
const todoTasks = tasks.filter((task) => task.status === 'Todo');
Finally, there is the reduce
function which (you guessed it) reduces an array to a single value.
The reduce
function moves over an array from left to right and keeps track of a value (a so-called accumulator).
At every element of the array it recomputes the accumulator based on a function f
(this function f
is the first argument of the reduce
function).
The second argument of the reduce function is the initial value.
Here is how we might compute the sum of an array:
const sum = numbers.reduce((acc, curr) => acc + curr, 0);
Basically, this is what happens:
The reduce
function looks at acc
(which is the initial value, i.e. 0
at the beginning) and curr
(which is 1
), produces acc + curr
, and sets this as the new accumulator (i.e. the new accumulator is 1
).
Next, the reduce
function again looks at acc
(which is now 1
) and curr
(which is 2
), produces acc + curr
, and sets this as the new accumulator (i.e. the new accumulator becomes 3
).
The next update results in the accumulator being 6
and the final update results in the accumulator being 10
.
Therefore, sum
becomes 10
.
For another example, let's say we would like to compute the total logged time (i.e. the time logged for all the tasks combined). This would look like this:
const totalTime = tasks.reduce((curr, task) => task.timeLogged + curr, 0);
We recommend that you try to reason through this
reduce
for a deeper understanding of this topic.
Note that unlike map
and filter
, you should use reduce
sparingly as it's very easy to write very convoluted code with reduce
.
If you find yourself writing extremely complicated reduce
expressions, you should consider using for loops and if statements instead.
Other Higher-Order Functions
While map
, filter
and reduce
are the most known higher-order functions, JavaScript provides us with many more.
It's worthwhile to get to know them since they will make a lot of tasks easier.
The find
method returns the first element in an array that satisfies some predicate:
const numbers = [2, 8, 4, 12, 6, 10];
console.log(numbers.find((x) => x > 9)); // 12
The findIndex
method is similar, except that it returns the index of the first element in an array that satisfies some predicate:
const numbers = [2, 8, 4, 12, 6, 10];
console.log(numbers.findIndex((x) => x > 9)); // 3
The every
method checks whether all elements in an array satisfy a predicate:
const numbers = [2, 8, 4, 12, 6, 10];
console.log(numbers.every((x) => x % 2 === 0)); // true
console.log(numbers.every((x) => x < 9)); // false
The some
method checks whether there is at least one element in an array that satisfies a predicate:
const numbers = [2, 8, 4, 12, 6, 10];
console.log(numbers.some((x) => x < 9)); // true
console.log(numbers.some((x) => x % 2 !== 0)); // false
The forEach
method executes some function for every array element.
This is very similar to a for loop (hence the name):
const numbers = [2, 8, 4, 12, 6, 10];
numbers.forEach((x) => console.log(x));
This will output:
2
8
4
12
6
10
Sorting Arrays
The sort
method can be used to sort the elements of an array:
const tasks = ['Task 2', 'Task 1', 'Task 3'];
tasks.sort();
console.log(tasks); // [ 'Task 1', 'Task 2', 'Task 3' ]
The default sort order is ascending and elements are sorted by converting them to strings and then sorting the strings lexicographically. However, this may not be what you want, especially when you're trying to sort numbers:
const numbers = [2, 8, 4, 12, 6, 10];
numbers.sort();
console.log(numbers); // [ 10, 12, 2, 4, 6, 8 ]
If you want to sort differently, you need to specify a comparison function compareFn
.
A comparison function takes two arguments a
and b
and returns a number whose sign indicates the relative order of the elements in the sorted array.
If compareFn(a, b)
is greater than 0
, a
should be sorted after b
.
If compareFn(a, b)
is smaller than 0
, a
should be sorted before b
.
If compareFn(a, b)
is equal to 0
, the original order of a
and b
should be kept.
For example, if you want to sort numbers in an ascending manner, you should specify the comparison function (x, y) => x - y
.
After all, if x - y
is greater than 0
then y
will be sorted after x
(which is exactly what you want).
Similarly, if x - y
is smaller than 0
then y
will be sorted before x
(which is again what you want).
const numbers = [2, 8, 4, 12, 6, 10];
numbers.sort((x, y) => x - y);
console.log(numbers); // [ 2, 4, 6, 8, 10, 12 ]
If you would want to sort numbers in a descending manner, you would need to change your comparison function to (x, y) => y - x
:
const numbers = [2, 8, 4, 12, 6, 10];
numbers.sort((x, y) => y - x);
console.log(numbers); // [ 12, 10, 8, 6, 4, 2 ]
You can use the comparison function to sort arbitrary objects as well.
For example, here is how you could sort the tasks
array by the timeLogged
property:
tasks.sort((task1, task2) => task1.timeLogged - task2.timeLogged);