new Array(3).map() does nothing, and the array still looks right
published
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:
| Operation | Sees a hole as | Result on [1, , 3] |
|---|---|---|
map(x => x) | skipped, hole preserved | [ 1, <1 empty item>, 3 ] |
forEach / filter / reduce | skipped | callback never fires for index 1 |
indexOf(undefined) | skipped | -1 |
includes(undefined) | undefined | true |
[...arr] / for...of / Array.from | undefined | [1, undefined, 3] |
JSON.stringify | null | '[1,null,3]' |
join('-') | empty string | '1--3' |
flat() | dropped | [1, 3] |
sort() | moved to the end | [ 1, 3, <1 empty item> ] |
at(1) | undefined | undefined |
1 in arr | absent | false |
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
- Everything above was checked against Node v26.2.0. The behaviour is specified, not implementation-defined, so other engines agree — but the
<N empty items>console formatting is a Node/V8 inspector convention, not part of the language. new Array(3)and[, , ]are not the same as[undefined, undefined, undefined]. The latter is dense and every method treats it normally. If you are writing test fixtures by hand, write the explicitundefineds.- Sparse arrays used to carry a real performance penalty because engines drop out of their fast packed-elements representation. Treat that as a reason to avoid holes rather than a number to quote — it depends on the engine and the access pattern.
delete arr[1]creates a hole. Usearr.splice(1, 1)if you meant to remove the element, or assignundefinedif you meant to blank it.- This has nothing to do with TypeScript.
number[]andArray<number>say nothing about density, so the type checker will not catch any of it.