for-of
vs. .reduce()
vs. .flatMap()
In this blog post, we look at three ways of processing Arrays:
for-of
loop.reduce()
.flatMap()
The goal is to help you choose between these features whenever you need to process Arrays. In case you don’t know .reduce()
and .flatMap()
yet, they will both be explained to you.
In order to get a better feeling for how these three features work, we use each of them to implement the following functionality:
Everything we do is non-destructive: The input Array is never changed. If the output is an Array, it is always freshly created.
for-of
loop This is how Arrays can be transformed non-destructively via for-of
:
result
and initializing it with an empty Array.elem
of the input Array:
result
:
elem
as necessary and push it into result
.for-of
Let’s get a feeling for processing Arrays via for-of
and implement (a simplified version of) the Array method .filter()
:
function filterArray(arr, callback) {
const result = [];
for (const elem of arr) {
if (callback(elem)) {
result.push(elem);
}
}
return result;
}
assert.deepEqual(
filterArray(['', 'a', '', 'b'], str => str.length > 0),
['a', 'b']
);
for-of
We can also use for-of
to implement the Array method .map()
.
function mapArray(arr, callback) {
const result = [];
for (const elem of arr) {
result.push(callback(elem));
}
return result;
}
assert.deepEqual(
mapArray(['a', 'b', 'c'], str => str + str),
['aa', 'bb', 'cc']
);
for-of
collectFruits()
returns all fruits that the persons in an Array have:
function collectFruits(persons) {
const result = [];
for (const person of persons) {
result.push(...person.fruits);
}
return result;
}
const PERSONS = [
{
name: 'Jane',
fruits: ['strawberry', 'raspberry'],
},
{
name: 'John',
fruits: ['apple', 'banana', 'orange'],
},
{
name: 'Rex',
fruits: ['melon'],
},
];
assert.deepEqual(
collectFruits(PERSONS),
['strawberry', 'raspberry', 'apple', 'banana', 'orange', 'melon']
);
for-of
The following code filters and maps in one step:
/**
* What are the titles of movies whose rating is at least `minRating`?
*/
function getTitles(movies, minRating) {
const result = [];
for (const movie of movies) {
if (movie.rating >= minRating) { // (A)
result.push(movie.title); // (B)
}
}
return result;
}
const MOVIES = [
{ title: 'Inception', rating: 8.8 },
{ title: 'Arrival', rating: 7.9 },
{ title: 'Groundhog Day', rating: 8.1 },
{ title: 'Back to the Future', rating: 8.5 },
{ title: 'Being John Malkovich', rating: 7.8 },
];
assert.deepEqual(
getTitles(MOVIES, 8),
['Inception', 'Groundhog Day', 'Back to the Future']
);
if
statement in line A and the .push()
method in line B.movie.title
(not the input element movie
).for-of
getAverageGrade()
computes the average grade of an Array of students:
function getAverageGrade(students) {
let sumOfGrades = 0;
for (const student of students) {
sumOfGrades += student.grade;
}
return sumOfGrades / students.length;
}
const STUDENTS = [
{
id: 'qk4k4yif4a',
grade: 4.0,
},
{
id: 'r6vczv0ds3',
grade: 0.25,
},
{
id: '9s53dn6pbk',
grade: 1,
},
];
assert.equal(
getAverageGrade(STUDENTS),
1.75
);
Caveat: Computing with decimal fractions can result in rounding errors (more information).
for-of
for-of
is also good at finding things in unsorted Arrays:
function findInArray(arr, callback) {
for (const [index, value] of arr.entries()) {
if (callback(value)) {
return {index, value}; // (A)
}
}
return undefined;
}
assert.deepEqual(
findInArray(['', 'a', '', 'b'], str => str.length > 0),
{index: 1, value: 'a'}
);
assert.deepEqual(
findInArray(['', 'a', '', 'b'], str => str.length > 1),
undefined
);
Here, we profit from being able to leave the loop early via return
once we have found something (line A).
for-of
When implementing the Array method .every()
, we once again profit from early loop termination (line A):
function everyArrayElement(arr, condition) {
for (const elem of arr) {
if (!condition(elem)) {
return false; // (A)
}
}
return true;
}
assert.equal(
everyArrayElement(['a', '', 'b'], str => str.length > 0),
false
);
assert.equal(
everyArrayElement(['a', 'b'], str => str.length > 0),
true
);
for-of
for-of
is a remarkably versatile tool when it comes to processing Arrays:
return
or break
.Other benefits of for-of
include:
for-await-of
loop.await
and yield
– in functions where these operators are allowed.A downside of for-of
is that it can be more verbose than alternatives – depending on what problem we are trying to solve.
for-of
yield
was already mentioned in the previous section but I additionally wanted to point out how convenient generators are for processing and producing synchronous and asynchronous iterables – think streams with on-demand processing of stream items.
As examples, let’s implement .filter()
and .map()
via synchronous generators:
function* filterIterable(iterable, callback) {
for (const item of iterable) {
if (callback(item)) {
yield item;
}
}
}
const iterable1 = filterIterable(
['', 'a', '', 'b'],
str => str.length > 0
);
assert.deepEqual(
Array.from(iterable1),
['a', 'b']
);
function* mapIterable(iterable, callback) {
for (const item of iterable) {
yield callback(item);
}
}
const iterable2 = mapIterable(['a', 'b', 'c'], str => str + str);
assert.deepEqual(
Array.from(iterable2),
['aa', 'bb', 'cc']
);
.reduce()
The Array method .reduce()
lets us compute summaries of Arrays. It is based on the following algorithm:
Before we get to .reduce()
itself, let’s implement its algorithm via for-of
. We’ll use concatenating an Array of strings as an example:
function concatElements(arr) {
let summary = ''; // initializing
for (const element of arr) {
summary = summary + element; // updating
}
return summary;
}
assert.equal(
concatElements(['a', 'b', 'c']),
'abc'
);
The Array method .reduce()
loops and keeps track of the summary for us, so that we can focus on initializing and updating. It uses the name “accumulator” as a rough synonym for “summary”. .reduce()
has two parameters:
In the following code, we use .reduce()
to implement concatElements()
:
const concatElements = (arr) => arr.reduce(
(accumulator, element) => accumulator + element, // updating
'' // initializing
);
.reduce()
.reduce()
is quite versatile. Let’s use it to implement filtering:
const filterArray = (arr, callback) => arr.reduce(
(acc, elem) => callback(elem) ? [...acc, elem] : acc,
[]
);
assert.deepEqual(
filterArray(['', 'a', '', 'b'], str => str.length > 0),
['a', 'b']
);
Alas, JavaScript Arrays are not very efficient when it comes to non-destructively adding elements to Arrays (in contrast to linked lists in many functional programming languages). Thus, mutating the accumulator is more efficient:
const filterArray = (arr, callback) => arr.reduce(
(acc, elem) => {
if (callback(elem)) {
acc.push(elem);
}
return acc;
},
[]
);
.reduce()
We can map via .reduce()
as follows:
const mapArray = (arr, callback) => arr.reduce(
(acc, elem) => [...acc, callback(elem)],
[]
);
assert.deepEqual(
mapArray(['a', 'b', 'c'], str => str + str),
['aa', 'bb', 'cc']
);
A mutatating version is again more efficient:
const mapArray = (arr, callback) => arr.reduce(
(acc, elem) => {
acc.push(callback(elem));
return acc;
},
[]
);
.reduce()
Expanding with .reduce()
:
const collectFruits = (persons) => persons.reduce(
(acc, person) => [...acc, ...person.fruits],
[]
);
const PERSONS = [
{
name: 'Jane',
fruits: ['strawberry', 'raspberry'],
},
{
name: 'John',
fruits: ['apple', 'banana', 'orange'],
},
{
name: 'Rex',
fruits: ['melon'],
},
];
assert.deepEqual(
collectFruits(PERSONS),
['strawberry', 'raspberry', 'apple', 'banana', 'orange', 'melon']
);
Mutating version:
const collectFruits = (persons) => persons.reduce(
(acc, person) => {
acc.push(...person.fruits);
return acc;
},
[]
);
.reduce()
Using .reduce()
to filter and map in one step:
const getTitles = (movies, minRating) => movies.reduce(
(acc, movie) => (movie.rating >= minRating)
? [...acc, movie.title]
: acc,
[]
);
const MOVIES = [
{ title: 'Inception', rating: 8.8 },
{ title: 'Arrival', rating: 7.9 },
{ title: 'Groundhog Day', rating: 8.1 },
{ title: 'Back to the Future', rating: 8.5 },
{ title: 'Being John Malkovich', rating: 7.8 },
];
assert.deepEqual(
getTitles(MOVIES, 8),
['Inception', 'Groundhog Day', 'Back to the Future']
);
More efficient mutating version:
const getTitles = (movies, minRating) => movies.reduce(
(acc, movie) => {
if (movie.rating >= minRating) {
acc.push(movie.title);
}
return acc;
},
[]
);
.reduce()
.reduce()
excels if we can compute a summary efficiently without mutating the accumulator:
const getAverageGrade = (students) => {
const sumOfGrades = students.reduce(
(acc, student) => acc + student.grade,
0
);
return sumOfGrades / students.length;
};
const STUDENTS = [
{
id: 'qk4k4yif4a',
grade: 4.0,
},
{
id: 'r6vczv0ds3',
grade: 0.25,
},
{
id: '9s53dn6pbk',
grade: 1,
},
];
assert.equal(
getAverageGrade(STUDENTS),
1.75
);
Caveat: Computing with decimal fractions can result in rounding errors (more information).
.reduce()
This is (a simplified version of) the Array method .find()
, implemented with .reduce()
:
const findInArray = (arr, callback) => arr.reduce(
(acc, value, index) => (acc === undefined && callback(value))
? {index, value}
: acc,
undefined
);
assert.deepEqual(
findInArray(['', 'a', '', 'b'], str => str.length > 0),
{index: 1, value: 'a'}
);
assert.deepEqual(
findInArray(['', 'a', '', 'b'], str => str.length > 1),
undefined
);
One limitation of .reduce()
is relevant here: Once we have found a value, we still have to visit the remaining elements because we can’t exit early. for-of
does not have this limitation.
.reduce()
This is (a simplified version of) the Array method .every()
, implemented with .reduce()
:
const everyArrayElement = (arr, condition) => arr.reduce(
(acc, elem) => !acc ? acc : condition(elem),
true
);
assert.equal(
everyArrayElement(['a', '', 'b'], str => str.length > 0),
false
);
assert.equal(
everyArrayElement(['a', 'b'], str => str.length > 0),
true
);
Again, this implementation could be more efficient if we could exit early from .reduce()
.
.reduce()
An upside of .reduce()
is its conciseness. A downside is that it can be difficult to understand – especially if you are not used to functional programming.
I use .reduce()
if:
reduce
for iterables..reduce()
is a good tool whenever a summary (such as the sum of all elements) can be computed without mutation.
Alas, JavaScript is not good at non-destructively and incrementally creating Arrays. That’s why I use .reduce()
less in JavaScript than the corresponding operations in languages that have built-in immutable lists.
.flatMap()
The normal .map()
method translates each input element to exactly one output element.
In contrast, .flatMap()
can translate each input element to zero or more output elements. To achieve that, the callback doesn’t return values, it returns Arrays of values:
assert.equal(
[0, 1, 2, 3].flatMap(num => new Array(num).fill(String(num))),
['1', '2', '2', '3', '3', '3']
);
.flatMap()
This is how we can filter with .flatMap()
:
const filterArray = (arr, callback) => arr.flatMap(
elem => callback(elem) ? [elem] : []
);
assert.deepEqual(
filterArray(['', 'a', '', 'b'], str => str.length > 0),
['a', 'b']
);
.flatMap()
This is how we can map with .flatMap()
:
const mapArray = (arr, callback) => arr.flatMap(
elem => [callback(elem)]
);
assert.deepEqual(
mapArray(['a', 'b', 'c'], str => str + str),
['aa', 'bb', 'cc']
);
.flatMap()
Filtering and mapping in one step is one of the strengths of .flatMap()
:
const getTitles = (movies, minRating) => movies.flatMap(
(movie) => (movie.rating >= minRating) ? [movie.title] : []
);
const MOVIES = [
{ title: 'Inception', rating: 8.8 },
{ title: 'Arrival', rating: 7.9 },
{ title: 'Groundhog Day', rating: 8.1 },
{ title: 'Back to the Future', rating: 8.5 },
{ title: 'Being John Malkovich', rating: 7.8 },
];
assert.deepEqual(
getTitles(MOVIES, 8),
['Inception', 'Groundhog Day', 'Back to the Future']
);
.flatMap()
Expanding input elements into zero or more output elements is another strength of .flatMap()
:
const collectFruits = (persons) => persons.flatMap(
person => person.fruits
);
const PERSONS = [
{
name: 'Jane',
fruits: ['strawberry', 'raspberry'],
},
{
name: 'John',
fruits: ['apple', 'banana', 'orange'],
},
{
name: 'Rex',
fruits: ['melon'],
},
];
assert.deepEqual(
collectFruits(PERSONS),
['strawberry', 'raspberry', 'apple', 'banana', 'orange', 'melon']
);
.flatMap()
can only produce Arrays With .flatMap()
, we can only produce Arrays. That prevents us from:
.flatMap()
.flatMap()
.flatMap()
We could conceivably produce a value wrapped in an Array. However, we can’t pass data between the invocations of the callback. That prevents us from, e.g., tracking if we have already found something. And we can’t exit early.
.flatMap()
.flatMap()
is good at:
I also find it relatively easy to understand. However, it’s not as versatile as for-of
and – to a lesser degree – .reduce()
:
So how do we best use these tools for processing Arrays? My rough general recommendations are:
.filter()
..map()
..some()
or .every()
.for-of
is the most versatile tool. In my experience:
.reduce()
and .flatMap()
.for-of
easier to understand. However, for-of
usually leads to more verbose code..reduce()
is good at computing summaries (such as the sum of all elements) if mutating the accumulator isn’t needed..flatMap()
excels at filter-mapping and expanding input elements into zero or more output elements.The following content is free to read online in my book “JavaScript for impatient programmers”: