JAVASCRIPT / ARRAYS
Indexing, length, and holes in sparse arrays
Explain what an array index really is, why length can exceed the number of stored elements, and how to detect, avoid, and remove holes.
What you will learn
- Read arr[2] as a lookup of the string key "2" on a plain object
- Tell a hole apart from a stored undefined using `2 in arr` or Object.keys
- Grow or truncate an array in one assignment by writing to arr.length
- Know forEach, map, and filter skip holes while includes and for...of see undefined
Understanding Indexing, length, and holes in sparse arrays
An array in JavaScript is an object, and its indexes are property keys rather than memory offsets. When you write scores[2], the engine converts 2 to the string "2" and looks up that property, which is why scores[2] and scores["2"] are the same slot. A key only counts as an array index if it is the canonical decimal form of an integer from 0 up to 2^32 - 2, so "2" qualifies while "02", "-1", and "2.5" become ordinary properties that merely happen to sit on an array.
The length property is a stored value, not a computed count. The engine maintains one invariant: length is always larger than every index that exists on the array. Assigning scores[6] on a three-element array therefore pushes length to 7 without creating anything at 3, 4, or 5. Length is writable in both directions too: lowering it deletes every index at or above the new value, while raising it only moves the boundary.
That gap between length and the keys that actually exist is what sparse means, and the missing positions are called holes. Reading a hole gives undefined for the same reason reading obj.nope gives undefined, namely that the key is absent and absence reads as undefined. Since a hole and a slot holding undefined are indistinguishable by value, you have to ask about the key instead, with 3 in arr or Object.hasOwn(arr, 3). The methods disagree as well: forEach, map, filter, reduce, and indexOf test whether each index exists and skip it if not, whereas includes, find, fill, for...of, and spread treat every position from 0 to length - 1 as a real slot containing undefined.
Because of that split behaviour, sparse arrays are a source of bugs rather than a feature worth using. Densify early with fill, Array.from, or an explicit loop so that the rest of your code can assume every index exists.
const scores = [10, 20, 30];
scores[6] = 70;
console.log('length:', scores.length);
console.log('scores[4]:', scores[4]);
console.log('4 in scores:', 4 in scores);
console.log('real keys:', Object.keys(scores).join(','));
let visited = 0;
scores.forEach(() => { visited += 1; });
console.log('forEach visited:', visited);
console.log('join:', scores.join('-'));
scores.length = 2;
console.log('after truncation:', scores.join('-'), scores.length);An array's length is one more than its highest existing index, not a count of stored values, so indexes can be missing entirely and reading one yields undefined without anything being there.
Worked examples
Hole versus stored undefined
Two arrays that look identical when read by index behave differently under key checks and iteration.
const holey = [1, , 3];
const dense = [1, undefined, 3];
console.log(holey.length, dense.length);
console.log(holey[1], dense[1]);
console.log(1 in holey, 1 in dense);
console.log(holey.indexOf(undefined), dense.indexOf(undefined));
console.log(holey.includes(undefined));
const doubled = holey.map(n => n * 2);
console.log(doubled.length, 1 in doubled);
console.log(holey.filter(() => true).length);Example explained
Line 1The elision in [1, , 3] sets length to 3 but creates no property at index 1.
Line 2holey[1] and dense[1] both read as undefined, so the subscript alone can never tell them apart.
Line 3indexOf visits only indexes that exist, so it misses the hole and returns -1 while finding the stored undefined at 1.
Line 4map skips the callback for the hole but copies the hole into the result, so doubled is still sparse; filter drops holes and returns a dense array of 2.
length is a writable knob
Assigning to length truncates or extends the array, and extending produces holes rather than values.
const letters = ['a', 'b', 'c', 'd'];
letters.length = 2;
console.log(letters.join(','));
letters.length = 4;
console.log(letters.length, 3 in letters);
console.log(JSON.stringify(letters));
letters[3] = 'z';
console.log(letters.length, 2 in letters);Example explained
Line 1letters.length = 2 deletes indexes 2 and 3 outright, which is the shortest way to drop a tail.
Line 2Raising length back to 4 creates nothing, so 3 in letters is false even though length says 4.
Line 3JSON has no notation for a hole, so JSON.stringify writes null and any parsed copy comes back dense.
Line 4Writing letters[3] fills only that one index; index 2 stays a hole and length is already 4, so it does not move.
Not every numeric key is an index
Only canonical non-negative integer keys affect length; the rest become plain object properties.
const a = [];
a[0] = 'zero';
a['1'] = 'one';
a[2.0] = 'two';
a[-1] = 'minus';
a['03'] = 'oh-three';
console.log(a.length);
console.log(a.join('|'));
console.log(a[-1], a['03']);
console.log(Object.keys(a).join(','));Example explained
Line 1a['1'] and a[2.0] both normalize to the index strings "1" and "2", so they push length to 3.
Line 2-1 and '03' are not canonical index strings, so they become ordinary properties and length ignores them.
Line 3join walks 0 to length - 1 only, which is why the two non-index properties never appear in the joined string.
Line 4Object.keys lists index keys first in numeric order, then other string keys in insertion order.
Important notes
Consoles invent their own notation for holes, with Node printing <1 empty item> and Chrome printing empty; nothing is stored there and that display is not a value you can compare against.
One far assignment such as arr[100000] = 1 can make the engine switch the array to a dictionary-style representation, so sparse arrays are slower to access as well as awkward to reason about.
Common mistakes
Treating length as an item count after assigning a far index, then looping 0 to length and calling a string method on a hole, which throws TypeError: Cannot read properties of undefined.
Using delete arr[i] to remove an element: the value disappears but length and every later index stay where they were, so the array still reports its old size; splice(i, 1) is what closes the gap.
Expecting new Array(5).map((_, i) => i) to give [0, 1, 2, 3, 4]; the callback never runs on holes, so you get five holes back, and Array.from({ length: 5 }, (_, i) => i) is the form that works.
Try it yourself
Change, predict, then run
In a browser console, build const a = ['x']; a[4] = 'y'; then write a loop over 0 to a.length - 1 that collects every i where !(i in a) and confirm you get [1, 2, 3]. Call a.fill('-', 1, 4) and run the same loop again to see the list come back empty.
Open the JavaScript workspaceCheck your understanding
Given const a = ['a']; a[3] = 'd'; and then let n = 0; a.forEach(() => n++); why does n end up as 2 while a.length is 4?
- forEach skips indexes that have no property, and only 0 and 3 exist; length just tracks the highest index plus one
- forEach stops as soon as it reaches an element that reads as undefined
- length counts stored values, and assigning a[3] happened to store two of them
- forEach visits all four indexes but the callback throws on the holes, so the counter is not incremented
Show answer
forEach checks whether each index actually exists before calling the callback, and the assignment to a[3] created only that one property, so it runs twice while length sits at 4. Option 2 is tempting because holes read as undefined, but forEach never stops early: if the array were ['a', undefined, undefined, 'd'] it would call the callback all four times, which shows that presence of the key, not the value, is what decides.