Proposal Draft for .flatten and .flatMap

Array prototype may be getting .flatten and .flatMap methods may be coming to ECMAScript in a distant future. This article describes what the proposal holds in store.

A very early draft was published last week by the ECMAScript editor Brian Terlson. When I say very early I mean it’s considered a “stage -1” proposal, meaning it’s not even a formal proposal yet, just a very early draft.

That being said, I’m always excited about new Array.prototype methods so I decided to write an article nonetheless. These kinds of methods were popularized in JavaScript by libraries like Underscore and then Lodash – and some of them – such as .includes, have eventually started finding their way into the language.

Shall we take a look?

A car compactor
A car compactor

Array.prototype.flatten

The .flatten proposal will take an array and return a new array where the old array was flattened recursively. The following bits of code represent the Array.prototype.flatten API.

[1, 2, 3, 4].flatten() // <- [1, 2, 3, 4]
[1, [2, 3], 4].flatten() // <- [1, 2, 3, 4]
[1, [2, [3]], 4].flatten() // <- [1, 2, 3, 4]

One could implement a polyfill for .flatten thus far like below. I separated the implementation of flatten from the polyfill so that you don’t necessarily have to use it as a polyfill if you just want to use the method without changing Array.prototype.

Array.prototype.flatten = function () {
  return flatten(this)
}
function flatten (list) {
  return list.reduce((a, b) => (Array.isArray(b) ? a.push(...flatten(b)) : a.push(b), a), [])
}

Keep in mind that the code above might not be the most efficient approach to array flattening, but it accomplishes recursive array flattening in a few lines of code. Here’s how it works.

The proposal also comes with an optional depth parameter – that defaults to Infinity which can be used to determine how deep the flattening should go.

[1, [2, [3]], 4].flatten() // <- [1, 2, 3, 4]
[1, [2, [3]], 4].flatten(2) // <- [1, 2, 3, 4]
[1, [2, [3]], 4].flatten(1) // <- [1, 2, [3], 4]
[1, [2, [3]], 4].flatten(0) // <- [1, [2, [3]], 4]

Adding the depth option to our polyfill wouldn’t be that hard, we pass it down to recursive flatten calls and ensure that, when the bottom is reached, we stop flattening and recursion.

Array.prototype.flatten = function (depth=Infinity) {
  return flatten(this, depth)
}
function flatten (list, depth) {
  if (depth === 0) {
    return list
  }
  return list.reduce((accumulator, item) => {
    if (Array.isArray(item)) {
      accumulator.push(...flatten(item, depth - 1))
    } else {
      accumulator.push(item)
    }
    return accumulator
  }, [])
}

Alternatively – for Internet points – we could fit the whole of flatten in a single expression.

function flatten (list, depth) {
  return depth === 0 ? list : list.reduce((a, b) => (Array.isArray(b) ?
    a.push(...flatten(b, depth - 1)) :
    a.push(b), a), [])
}

Then there’s .flatMap.

Array.prototype.flatMap

This method is convenient because of how often use cases come up where it might be appropriate, and at the same time it provides a small boost in performance, as we’ll note next.

Taking into account the polyfill we created earlier for flattening through Array.prototype.flatten, the .flatMap method can be represented in code like below. Note how you can provide a mapping function fn and its ctx context as usual, but the flattening is fixed at a depth of 1.

Array.prototype.flatMap = function (fn, ctx) {
  return this.map(fn, ctx).flatten(1)
}

Typically, the code shown above is how you would implement .flatMap in user code, but the native .flatMap trades a bit of readability for performance, by introducing the ability to map items directly in the internal flatten procedure, avoiding the two-pass that’s necessary if we first .map and then .flatten an Array.

A possible example of using .flatMap can be found below.

[{ x: 1, y: 2 }, { x: 3, y: 4 }, { x: 5, y: 6 }].flatMap(c => [c.x, c.y])
// <- [1, 2, 3, 4, 5, 6]

The above is syntactic sugar for doing .map(c => [c.x, c.y]).flatten() while providing a small performance boost by avoiding the aforementioned two-pass when first mapping and then flattening.

Note that our previous polyfill doesn’t cover the performance boost, let’s fix that by changing our own internal flatten function and adjust Array.prototype.flatMap accordingly. We’ve added a couple more parameters to flatten, where we allow the item to be mapped into a different value right before flattening, and avoiding the extra loop over the array.

Array.prototype.flatMap = function (fn, ctx) {
  return flatten(this, 1, fn, ctx)
}
function flatten (list, depth, mapperFn, mapperCtx) {
  if (depth === 0) {
    return list
  }
  return list.reduce((accumulator, item, i) => {
    if (mapperFn) {
      item = mapperFn.call(mapperCtx || list, item, i, list)
    }
    if (Array.isArray(item)) {
      accumulator.push(...flatten(item, depth - 1))
    } else {
      accumulator.push(item)
    }
    return accumulator
  }, [])
}

Since the mapperFn and mapperCtx parameters of flatten are entirely optional, we could still use this same internal flatten function to polyfill both .flatten and .flatMap.

⏪