JS protip: Flattening nested arrays
By Christophe Porteneuve • Published on 7 December 2022
• 3 min
Cette page est également disponible en français.
Sometimes we end up with arrays… within arrays. Perhaps a .map()
went astray, or we had a recursive processing, or nested data sources: reasons are many, and oftentimes such a nested data structure is fine with us.
But when we want to process that as one sequence, without depth-first traversals, how should we go about it?
Until recently, we had to turn to third-party libraries, perhaps Lodash and its .flattenDeep()
or .flatMapDepth()
functions. But ever since ES2019, this has been available straight in JavaScript’s standard library!
Flattening, more or less
Arrays offer a .flat()
method, that accepts an optional depth. It defaults to 1 (one), thus flattening only one level down:
items = ['hello', ['world', 'this'], 'is', [['nice'], '!']]
items.flat()
// => ['hello', 'world', 'this', 'is', ['nice'], '!']
Naturally, to flatten all the way, you just need to pass a sufficient depth. A “guaranteed” flattening could just pass in… +Infinity
or Number.POSITIVE_INFINITY
, by the way.
items = ['hello', ['world', 'this'], 'is', [['nice'], '!']]
items.flat(2)
// => ['hello', 'world', 'this', 'is', 'nice', '!']
Wait a second… This is not a verb!
Indeed. The appropriate verb would be .flatten()
. Unfortunately, as is too often the case, numerous websites still rely on MootTools (our usual suspect for this). It defined .flatten()
on Array
already, except with different semantics (it always flattens all the way), thereby preventing TC39 from using that nicer name. (If you ever wondered why we landed on .includes()
instead of .contains()
on strings and arrays, now you know.)
A note about sparse arrays
It’s worth mentioning that missing cells in sparse arrays are ignored by flattening, which therefore never produces a sparse array. This is a neat way of “compacting” a sparse array.
items = ['hello', 'world', , , 'this', 'is', , 'nice']
items.length
// => 8
Object.keys(items)
// => ['0', '1', '4', '5', '7']
items.flat()
// => ['hello', 'world', 'this', 'is', 'nice']
Transforming on-the-fly
We often find ourselves wanting to apply a tranform to arrays before flattening them; we could do a .map()
first then a .flat()
, but it would be a shame to do two traversals instead of one. We also need a neat solution for the common use-case when we .map()
using a callback that produces an array we want to inline in the result, keeping it flat.
We can optimize all this with .flatMap()
. It has the exact same signature as .map()
(a callback accepting up to 3 arguments and, if that callback isn’t an arrow function, an optional this
to be set inside the callback).
borked = [1, 2, 3].map((x) => [x, x * 2, x * 3])
// => [[1, 2, 3], [2, 4, 6], [3, 6, 9]]
multiples = [1, 2, 3].flatMap((x) => [x, x * 2, x * 3])
// => [1, 2, 3, 2, 4, 6, 3, 6, 9]
Beware! This is not akin to first calling .flat()
, then calling .map()
: it is the very opposite! Our mapper gets all sources items directly, untouched, including nested arrays that are passed as arrays, not as individual items. This does serve well the use-case of mappers producing arrays that need to be inlined.
This is also why .flatMap()
has no optional maximum depth argument: as our callback gets nested arrays as arrays, it can decide whether to .flatMap()
recursively or not, depending on our needs.
Arrays or iterables?
It would be awesome to get this across all iterables (and iterators, for that matter), but we’ll have to wait until the Iterator helpers proposal clears standardization stage 4 for this. It just made stage 3. We’ll then get a ton of cool methods on all iterators, including .flat()
and .flatMap()
.
For now, stick with arrays.
Where can I use that?
Well, everywhere. You’re good to go with any modern browser, plus Node 11+ and Deno 1.0+. So go for it!
Protips galore!
We got tons of articles, with a lot more to come. Also check out our kick-ass training courses 🔥!