Exploring ES2018 and ES2019
Please support this book: buy it or donate
(Ad, please don’t block.)

13. Array.prototype.{flat,flatMap}



13.1. Overview

The ES2019 feature Array.prototype.{flat,flatMap} (by Michael Ficarra, Brian Terlson, Mathias Bynens) adds two new methods to Arrays (to Array<T>.prototype): .flat() and .flatMap().

13.1.1. .flat()

The type signature of Array<T>.prototype.flat() is:

.flat(depth = 1): any[]

.flat() “flattens” an Array: It creates a copy of the Array where values in nested Arrays all appear at the top level. The parameter depth controls how deeply .flat() looks for non-Array values. For example:

> [ 1,2, [3,4], [[5,6]] ].flat(0) // no change
[ 1, 2, [ 3, 4 ], [ [ 5, 6 ] ] ]

> [ 1,2, [3,4], [[5,6]] ].flat(1)
[ 1, 2, 3, 4, [ 5, 6 ] ]

> [ 1,2, [3,4], [[5,6]] ].flat(2)
[ 1, 2, 3, 4, 5, 6 ]

13.1.2. .flatMap()

The type signature of Array<T>.prototype.flatMap() is:

.flatMap<U>(
  callback: (value: T, index: number, array: T[]) => U|Array<U>,
  thisValue?: any
): U[]

.flatMap() is the same as first calling .map() and then flattening the result. That is, the following two expressions are equivalent:

arr.flatMap(func)
arr.map(func).flat(1)

For example:

> ['a', 'b', 'c'].flatMap(x => x)
[ 'a', 'b', 'c' ]
> ['a', 'b', 'c'].flatMap(x => [x])
[ 'a', 'b', 'c' ]
> ['a', 'b', 'c'].flatMap(x => [[x]])
[ [ 'a' ], [ 'b' ], [ 'c' ] ]

> ['a', 'b', 'c'].flatMap((x, i) => new Array(i+1).fill(x))
[ 'a', 'b', 'b', 'c', 'c', 'c' ]

13.2. More information on .flatMap()

Both .map() and .flatMap() take a function f as a parameter that controls how an input Array is translated to an output Array:

This is an implementation of .flatMap() (a simplified version of JavaScript’s implementation that does not conform to the specification):

function flatMap(arr, mapFunc) {
  const result = [];
  for (const [index, elem] of arr.entries()) {
    const x = mapFunc(elem, index, arr);
    // We allow mapFunc() to return non-Arrays
    if (Array.isArray(x)) {
      result.push(...x);
    } else {
      result.push(x);
    }
  }
  return result;
}

.flatMap() is simpler if mapFunc() is only allowed to return Arrays, but JavaScript doesn’t impose this restriction, because non-Array values are occasionally useful (see the section on .flat() for an example).

What is .flatMap() good for? Let’s look at use cases!

13.3. Use case: filtering and mapping at the same time

The result of the Array method .map() always has the same length as the Array it is invoked on. That is, its callback can’t skip Array elements it isn’t interested in.

The ability of .flatMap() to do so is useful in the next example: processArray() returns an Array where each element is either a wrapped value or a wrapped error.

function processArray(arr, process) {
  return arr.map(x => {
    try {
      return { value: process(x) };
    } catch (e) {
      return { error: e };
    }
  });
}

The following code shows processArray() in action:

let err;
function myFunc(value) {
  if (value < 0) {
    throw (err = new Error('Illegal value: '+value));
  }
  return value;
}
const results = processArray([1, -5, 6], myFunc);
assert.deepEqual(results, [
  { value: 1 },
  { error: err },
  { value: 6 },
]);

.flatMap() enables us to extract just the values or just the errors from results:

const values = results.flatMap(
  result => result.value ? [result.value] : []);
assert.deepEqual(values, [1, 6]);
  
const errors = results.flatMap(
  result => result.error ? [result.error] : []);
assert.deepEqual(errors, [err]);

13.4. Use case: mapping to multiple values

The Array method .map() maps each input Array element to one output element. But what if we want to map it to multiple output elements?

That becomes necessary in the following example: The React component TagList is invoked with two attributes.

<TagList tags={['foo', 'bar', 'baz']}
        handleClick={x => console.log(x)} />

The attributes are:

TagList is rendered as a series of links separated by commas:

class TagList extends React.Component {
  render() {
    const {tags, handleClick} = this.props;
    return tags.flatMap(
      (tag, index) => {
        const link = <a key={index} href=""
           onClick={e => handleClick(tag, e)}>
           {tag}
        </a>;
        if (index === 0) {
          return [link];
        } else {
          return [', ', link];
        }
      }
    );
  }
}

Due to .flatMap(), TagList is rendered as a single flat Array. The first tag contributes one element to this Array (a link); each of the remaining tags contributes two elements (comma and link).

13.5. Other versions of .flatMap()

13.5.1. Arbitrary iterables

.flatMap() can be generalized to work with arbitrary iterables:

function* flatMapIter(iterable, mapFunc) {
  let index = 0;
  for (const x of iterable) {
    yield* mapFunc(x, index);
    index++;
  }
}

Due to Arrays being iterables, you can process them via flatMapIter():

function fillArray(x) {
  return new Array(x).fill(x);
}
const iterable = flatMapIter([1,2,3], fillArray);
assert.deepEqual(
  [...iterable], // convert to Array, to check contents
  [1, 2, 2, 3, 3, 3]);

One benefit of flatMapIter() is that it works incrementally: as soon as the first input value is available, output is produced. In contrast, the Array-based .flatMap() needs all of its input to produce its output.

That can be demonstrated via the infinite iterable created by the generator function naturalNumbers():

function* naturalNumbers() {
  for (let n=0;; n++) {
    yield n;
  }
}
const infiniteInput = naturalNumbers();
const infiniteOutput = flatMapIter(infiniteInput, fillArray);
const [a,b,c,d,e] = infiniteOutput; // (A)
assert.deepEqual([a,b,c,d,e], [1, 2, 2, 3, 3]);

In line A, we extract the first 5 values of infiniteOutput via destructuring.

13.5.2. Implementing .flatMap() via .reduce()

We can use the Array method .reduce() to implement a simple version of .flatMap():

function flatMap(arr, mapFunc) {
  return arr.reduce(
    (prev, x) => prev.concat(mapFunc(x)),
    []
  );
}

It depends on your taste, if you prefer the original, more efficient imperative version or this more concise functional version.

13.6. More information on .flat()

This is an implementation of .flat() (a simplified version of JavaScript’s implementation, that does not conform to the ECMAScript specification):

function flat(arr, depth) {
  return flatInto(arr, depth, []);
}
function flatInto(value, depth, target) {
  if (!Array.isArray(value)) {
    target.push(value);
  } else {
    for (const x of value) {
      if (depth >= 1) {
        flatInto(x, depth-1, target);
      } else {
        target.push(x);
      }
    }    
  }
  return target;
}

.flat() with a depth of 1 can also be implemented as follows:

const flat = (arr) => [].concat(...arr)

.flat(1) is the same as using .flatMap() with the identity function (x => x). That is, the following two expressions are equivalent:

arr.flatMap(x => x)
arr.flat(1)

The next subsections cover use cases for .flat().

13.6.1. Use case: conditionally inserting values into an Array

The following code only inserts 'a' if cond is true:

const cond = false;
const arr = [
  (cond ? 'a' : []),
  'b',
].flat();
assert.deepEqual(arr, ['b']);

Caveat: If you replace either 'a' or 'b' with an Array, then you have to wrap it in another Array.

13.6.2. Use case: filtering out failures

In the following example, downloadFiles() only returns the texts that could be downloaded.

async function downloadFiles(urls) {
  const downloadAttempts = await Promises.all( // (A)
    urls.map(url => downloadFile(url)));
  return downloadAttempts.flat(); // (B)
}
async function downloadFile(url) {
  try {
    const response = await fetch(url);
    const text = await response.text();
    return [text]; // (C)
  } catch (err) {
    return []; // (D)
  }
}

downloadFiles() first maps each URL to a Promise resolving to either:

Promises.all() (line A) converts the Array of Promises into a Promise that resolves to a nested Array. await (line A) unwraps that Promise and .flat() un-nests the Array (line B).

Note that we couldn’t have used .flatMap() here, because of the barrier imposed by the Promises returned by downloadFile(): when it returns a value, it doesn’t know yet if it will be a text or an empty Array.

13.7. FAQ

13.7.1. Do .flat() and .flatMap() also flatten iterable elements?

No, only Arrays are flattened:

const set = new Set([3,4]);
assert.deepEqual(
  [[1,2], set].flat(),
  [1, 2, set]);

13.8. Further reading