~/blog

new Array(3).map() does nothing, and the array still looks right

published

#javascript#arrays#gotcha

TL;DR

new Array(3) creates three holes, not three undefineds. map, forEach, filter, some, every and reduce skip holes entirely — the callback never runs — while spread, for...of, Array.from and JSON.stringify turn them into undefined. Use Array.from({ length: n }, fn) or new Array(n).fill(0) and the problem disappears.

The problem

You want an array of three indices. This is the obvious thing to write:

const ids = new Array(3).map((_, i) => i);
console.log(ids);        // [ <3 empty items> ]
console.log(ids.length); // 3

The length is right. The contents are not — the callback ran zero times. Worse, it does not throw, and in most log output the result looks close enough to correct that it slides through review.

The same array answers two different questions two different ways:

const a = new Array(3);

console.log(a.filter(() => true));  // []      -- looks empty
console.log([...a]);                // [ undefined, undefined, undefined ]
console.log(a.length);              // 3
console.log(Object.keys(a));        // []      -- no own index properties

An array that is simultaneously length 3, has no keys, filters down to nothing, and spreads into three values.

Why it happens

Arrays in JavaScript are objects with integer-like keys. new Array(3) sets length to 3 without creating the properties 0, 1, 2. Those missing indices are holes, and the array is sparse.

The split is historical. The iteration methods from ES5 — map, forEach, filter, reduce and friends — check HasProperty before each step and skip indices that are absent. The iterator protocol, which for...of and spread use, does not: it walks 0 to length - 1 and reads each index, and reading a missing property gives undefined.

So it is not one rule with exceptions. It is two mechanisms that disagree:

OperationSees a hole asResult on [1, , 3]
map(x => x)skipped, hole preserved[ 1, <1 empty item>, 3 ]
forEach / filter / reduceskippedcallback never fires for index 1
indexOf(undefined)skipped-1
includes(undefined)undefinedtrue
[...arr] / for...of / Array.fromundefined[1, undefined, 3]
JSON.stringifynull'[1,null,3]'
join('-')empty string'1--3'
flat()dropped[1, 3]
sort()moved to the end[ 1, 3, <1 empty item> ]
at(1)undefinedundefined
1 in arrabsentfalse

Note rows 3 and 4 in particular: indexOf and includes disagree about the same array, because includes was specified later (ES2016) and deliberately reads holes rather than skipping them.

slice() and concat() also preserve holes, so copying an array does not fix it — the sparseness travels.

What to do

Do not create holes. Two idioms, both dense:

// dense from the start, callback runs per index
Array.from({ length: 3 }, (_, i) => i);   // [0, 1, 2]

// fill first, then map
new Array(3).fill(0).map((_, i) => i);    // [0, 1, 2]

fill works because it writes each index, converting the holes to real properties. Array.from never makes holes at all — it builds from an iterable or an array-like, and array-likes have no concept of a hole.

A third option when you only need indices:

[...new Array(3).keys()];  // [0, 1, 2]

keys() returns an iterator, and iterators do not skip.

The ES2023 copying methods are dense

toSorted, toReversed, toSpliced and with return dense arrays even from a sparse input — holes come back as undefined:

[1, , 3].toSorted();     // [ 1, 3, undefined ]
[1, , 3].with(1, 9);     // [ 1, 9, 3 ]

// contrast with the in-place original
[1, , 3].sort();         // [ 1, 3, <1 empty item> ]

That is a deliberate design decision in the change-array-by-copy proposal, and a good reason to prefer the copying variants where you have the choice.

Spotting it

Object.keys(arr).length !== arr.length means the array is sparse. In Node and Chrome DevTools, the console prints <N empty items> rather than undefined — that phrasing is the tell, and it is easy to miss when you are skimming.

Caveats

References