Processing Arrays non-destructively: for-of vs. .reduce() vs. .flatMap()

[2022-05-26] dev, javascript
(Ad, please don’t block)

In this blog post, we look at three ways of processing Arrays:

  • The for-of loop
  • The Array method .reduce()
  • The Array method .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:

  • Filtering an input Array to produce an output Array
  • Mapping each input Array element to one output Array element
  • Expanding each input Array element to zero or more output Array elements
  • Filter-mapping (filtering and mapping in one step)
  • Computing a summary for an Array
  • Finding an Array element
  • Checking a condition for all Array elements

Everything we do is non-destructive: The input Array is never changed. If the output is an Array, it is always freshly created.

Processing Arrays via the for-of loop  

This is how Arrays can be transformed non-destructively via for-of:

  • Start by declaring the variable result and initializing it with an empty Array.
  • For each element elem of the input Array:
    • If a value should be added to result:
      • Transform elem as necessary and push it into result.

Filtering with 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']
);

Mapping with 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']
);

Expanding with 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']
);

Filter-mapping with 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']
);
  • Filtering is done via the if statement in line A and the .push() method in line B.
  • Mapping is done by pushing movie.title (not the input element movie).

Computing summaries with 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).

Finding with 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).

Checking a condition with 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
);

When to use for-of  

for-of is a remarkably versatile tool when it comes to processing Arrays:

  • Creating output Arrays via pushing is easy to understand.
  • When the result isn’t an Array, it’s often useful that we can finish early via return or break.

Other benefits of for-of include:

A downside of for-of is that it can be more verbose than alternatives – depending on what problem we are trying to solve.

Generators and 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']
);

The Array method .reduce()  

The Array method .reduce() lets us compute summaries of Arrays. It is based on the following algorithm:

  • [Initializing the summary] We initialize the summary with a value that works for empty Arrays.
  • We loop over the Array. Per Array element:
    • [Updating the summary] We compute a new summary by combining the old summary with the current element.

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:

  1. A callback:
    • Input: old accumulator and current element
    • Output: new accumulator
  2. The initial value of the accumulator.

In the following code, we use .reduce() to implement concatElements():

const concatElements = (arr) => arr.reduce(
  (accumulator, element) => accumulator + element, // updating
  '' // initializing
);

Filtering with .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;
  },
  []
);

Mapping with .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;
  },
  []
);

Expanding with .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;
  },
  []
);

Filter-mapping with .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;
  },
  []
);

Computing summaries with .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).

Finding with .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.

Checking a condition with .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().

When to use .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:

  • I don’t need to mutate the accumulator.
  • I don’t need to exit early.
  • I don’t need support for synchronous or asynchronous 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.

The Array method .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']
);

Filtering with .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']
);

Mapping with .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']
);

Filter-mapping with .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']
);

Expanding with .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:

  • Computing summaries with .flatMap()
  • Finding with .flatMap()
  • Checking a condition with .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.

When to use .flatMap()  

.flatMap() is good at:

  • Filtering and mapping at the same time
  • Expanding input elements into zero or more output elements

I also find it relatively easy to understand. However, it’s not as versatile as for-of and – to a lesser degree – .reduce():

  • It can only produce Arrays as results.
  • We can’t pass data between the invocations of the callback.
  • We can’t exit early.

Recommendations  

So how do we best use these tools for processing Arrays? My rough general recommendations are:

  • Use the most specific tool you have for the task:
    • Do you need to filter? Use .filter().
    • Do you need to map? Use .map().
    • Do you need to check a condition for elements? Use .some() or .every().
    • Etc.
  • for-of is the most versatile tool. In my experience:
    • People who are familiar with functional programming, tend to prefer .reduce() and .flatMap().
    • People who aren’t, often find 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.

Further reading  

The following content is free to read online in my book “JavaScript for impatient programmers”: