08 — Array Internals: Sparse Holes, Iteration Methods & Mutation Semantics
Array Holes: Empty Slots vs undefined
javascript
// ── Arrays with holes: empty slots (not the same as undefined) ──
const arr = [1, , 3]; // hole at index 1 (comma with nothing between)
console.log(arr.length); // 3
console.log(arr[1]); // undefined (accessing a hole returns undefined)
console.log(1 in arr); // false — index 1 has NO element (it's a hole)
console.log(arr.hasOwnProperty(1)); // false — no property at index 1
// ── Holes vs explicit undefined: behavior differs across methods ──
const withHole = [1, , 3];
const withUndefined = [1, undefined, 3];
// ── forEach: SKIPS holes, visits undefined ──
withHole.forEach(x => console.log(x)); // 1, 3 (skips index 1!)
withUndefined.forEach(x => console.log(x)); // 1, undefined, 3 (visits all)
// ── map: SKIPS holes (preserves them) ──
console.log(withHole.map(x => x * 2)); // [2, <empty>, 6] (hole preserved)
console.log(withUndefined.map(x => x * 2)); // [2, NaN, 6] (undefined * 2 = NaN)
// ── filter: skips holes ──
console.log(withHole.filter(() => true)); // [1, 3] (hole skipped, not in result)
console.log(withUndefined.filter(() => true)); // [1, undefined, 3] (undefined kept)
// ── spread and Array.from: convert holes to undefined ──
console.log([...withHole]); // [1, undefined, 3] (holes → undefined)
console.log(Array.from(withHole)); // [1, undefined, 3]
// ── Creating arrays with Array(n) (all holes) ──
const empty = Array(3); // [empty × 3] — 3 holes (NOT 3 undefined values!)
console.log(empty.map(x => x)); // [empty × 3] — map skips holes, does nothing
console.log([...Array(3)]); // [undefined, undefined, undefined] — spread fills with undefined
console.log(Array(3).fill(0)); // [0, 0, 0] — fill replaces holes with a value
// ── Array(n) vs Array.of(n) ──
Array(3); // [empty × 3] — creates array with 3 holes (length 3)
Array.of(3); // [3] — creates array with 3 as a single element
Array(1, 2, 3); // [1, 2, 3] — multiple args → array of args
Array.of(1, 2, 3); // [1, 2, 3] — same with multiple args
Mutation vs Non-Mutation Methods
javascript
// ── MUTATING methods (modify the original array) ──
const arr = [3, 1, 2];
arr.push(4); // [3, 1, 2, 4] — adds to end, returns new length
arr.pop(); // returns 4, arr is [3, 1, 2] — removes from end
arr.unshift(0); // [0, 3, 1, 2] — adds to start (O(n) — shifts all elements!)
arr.shift(); // returns 0, arr is [3, 1, 2] — removes from start (O(n))
arr.sort(); // [1, 2, 3] — sorts IN PLACE (returns same array, modified!)
arr.reverse(); // [3, 2, 1] — reverses IN PLACE
arr.splice(1, 1); // removes 1 element at index 1 — [3, 1] (mutates)
arr.fill(0); // [0, 0] — fills with 0 IN PLACE
// ── NON-MUTATING methods (return a new array) ──
const arr2 = [3, 1, 2];
arr2.map(x => x * 2); // [6, 2, 4] — new array, original unchanged
arr2.filter(x => x > 1); // [3, 2] — new array
arr2.slice(0, 2); // [3, 1] — new array (subarray)
arr2.concat([4, 5]); // [3, 1, 2, 4, 5] — new array
arr2.flatMap(x => [x, x]); // [3, 3, 1, 1, 2, 2] — new array
[...arr2].sort(); // [1, 2, 3] — copy + sort (original unchanged)
Anti-Pattern: sort() Without a Comparator
javascript
// ❌ NAIVE — .sort() without a comparator converts to STRING and sorts lexicographically
[10, 2, 1, 21].sort(); // [1, 10, 2, 21] — string sort! ("10" < "2" because "1" < "2")
[100, 1, 2].sort(); // [1, 100, 2] — string sort!
["banana", "apple", "cherry"].sort(); // ["apple", "banana", "cherry"] — correct for strings
// ✅ CORRECT — numeric sort with comparator
[10, 2, 1, 21].sort((a, b) => a - b); // [1, 2, 10, 21] — numeric ascending
[10, 2, 1, 21].sort((a, b) => b - a); // [21, 10, 2, 1] — numeric descending
// ── Comparator semantics ──
// (a, b) => a - b → ascending (return <0: a first, >0: b first, 0: equal)
// (a, b) => b - a → descending
// ⚠️ The comparator must return a NUMBER (not boolean). Returning true/false is a bug.
// ── Sort by multiple criteria ──
const people = [
{ name: "Alice", age: 30 },
{ name: "Bob", age: 30 },
{ name: "Charlie", age: 25 },
];
people.sort((a, b) => a.age - b.age || a.name.localeCompare(b.name));
// Sort by age ascending, then name ascending (for ties)
// `||` falls through to the next criterion when the first returns 0 (tie)
// ── ⚠️ .sort() MUTATES the original array ──
const original = [3, 1, 2];
const sorted = original.sort(); // both `original` and `sorted` are [1, 2, 3]
console.log(original === sorted); // true — same reference, original was modified!
// ✅ Non-mutating sort: const sorted = [...original].sort();
reduce: The Fold Pattern
javascript
// ── reduce: fold left (processes left-to-right) ──
// Signature: arr.reduce((accumulator, current, index, array) => newAccumulator, initialValue)
// ── Sum ──
const sum = [1, 2, 3, 4].reduce((acc, cur) => acc + cur, 0); // 10
// Step: 0+1=1, 1+2=3, 3+3=6, 6+4=10
// ── Product ──
const product = [1, 2, 3, 4].reduce((acc, cur) => acc * cur, 1); // 24
// ── Group by (object accumulator) ──
const people = [
{ role: "admin", name: "Alice" },
{ role: "user", name: "Bob" },
{ role: "admin", name: "Charlie" },
];
const grouped = people.reduce((acc, person) => {
(acc[person.role] ??= []).push(person.name); // ??= creates array if not exists
return acc;
}, {});
// { admin: ["Alice", "Charlie"], user: ["Bob"] }
// ── reduceRight: fold right (processes right-to-left) ──
// Useful for building nested structures from inside-out
const piped = [double, addOne, square].reduceRight((acc, fn) => fn(acc), 5);
// Right-to-left: square(5)=25 → addOne(25)=26 → double(26)=52
// ── ⚠️ reduce on empty array without initial value throws ──
[].reduce((acc, cur) => acc + cur); // TypeError: Reduce of empty array with no initial value
[].reduce((acc, cur) => acc + cur, 0); // 0 — safe with initial value
// ALWAYS provide an initial value to avoid the empty array error and for clarity.
Production Pattern: Pipeline Composition
javascript
// ── Function pipeline using reduce ──
const pipe = (...fns) => (input) => fns.reduce((acc, fn) => fn(acc), input);
const pipeline = pipe(
x => x + 1, // 5 → 6
x => x * 2, // 6 → 12
x => x - 3, // 12 → 9
);
console.log(pipeline(5)); // 9
// ── Async pipeline (each step is async) ──
const pipeAsync = (...fns) => (input) =>
fns.reduce(async (acc, fn) => fn(await acc), Promise.resolve(input));
const asyncPipeline = pipeAsync(
async x => { await delay(100); return x + 1; },
async x => { await delay(100); return x * 2; },
);
asyncPipeline(5).then(console.log); // 12 (after ~200ms)
// ── Array flattening with flat and flatMap ──
[[1, 2], [3, 4], [5]].flat(); // [1, 2, 3, 4, 5] (1 level deep)
[[1, [2, [3]]]].flat(); // [1, 2, [3]] (1 level — [3] still nested)
[[1, [2, [3]]]].flat(Infinity); // [1, 2, 3] (fully flat)
[1, 2, 3].flatMap(x => [x, x * 2]); // [1, 2, 2, 4, 3, 6] (map + flat in one pass)
// ── Chaining array methods (each creates a new array) ──
const result = [1, 2, 3, 4, 5, 6]
.filter(x => x % 2 === 0) // [2, 4, 6]
.map(x => x * x) // [4, 16, 36]
.reduce((a, b) => a + b, 0); // 56
// ⚠️ Each method creates an intermediate array (3 arrays total).
// For large arrays, a single reduce loop is more efficient (one pass, no intermediates).
Array-Like Objects and Typed Arrays
javascript
// ── Array-like: has .length and indexed access, but NOT array methods ──
const arrayLike = { 0: "a", 1: "b", length: 2 };
// arrayLike.map(...) // TypeError: arrayLike.map is not a function
console.log(arrayLike[0]); // "a" (indexed access works)
console.log(arrayLike.length); // 2
// ── Convert array-like to real array ──
Array.from(arrayLike); // ["a", "b"] — creates a real Array
[...arrayLike]; // ["a", "b"] — spread works on iterables (but arrayLike isn't iterable by default!)
// Actually: spread works on iterables; array-like objects aren't iterable unless they have Symbol.iterator.
// For array-like: use Array.from() (handles array-like) or Array.prototype.slice.call(arrayLike).
// ── Real array-like objects ──
// `arguments` object (in regular functions):
function example() {
console.log(arguments); // [Arguments] { '0': 1, '1': 2 } — array-like
const args = Array.from(arguments); // convert to real array
console.log(args.map(x => x * 2));
}
example(1, 2);
// NodeList (from DOM):
// document.querySelectorAll("div") — array-like, not a real Array
// const divs = Array.from(document.querySelectorAll("div"));
// ── Typed arrays: fixed-length, typed numeric arrays (for binary data) ──
const uint8 = new Uint8Array([255, 256, 257]); // [255, 0, 1] (overflow: 256→0, 257→1)
const int32 = new Int32Array([1, 2, 3]); // 32-bit signed integers
const float64 = new Float64Array([1.5, 2.5]); // 64-bit floats
// Typed arrays:
// - Fixed length (can't push/pop)
// - All elements are the same type (no mixed types)
// - Memory-efficient (no boxing, contiguous memory)
// - Used for: binary protocols, canvas, WebGL, file I/O, crypto
const buffer = new ArrayBuffer(16); // 16 bytes of raw memory
const view = new Uint8Array(buffer); // view the buffer as bytes
view[0] = 255;
💡 Tips & Tricks
javascript
// ── Array.from with a mapping function (map during creation) ──
Array.from({ length: 5 }, (_, i) => i); // [0, 1, 2, 3, 4] — range generator
Array.from({ length: 3 }, () => Math.random()); // [random, random, random]
// ── Array.from with Set for deduplication ──
const unique = Array.from(new Set([1, 2, 2, 3, 3, 3])); // [1, 2, 3]
// Or: [...new Set([1, 2, 2, 3])] — spread a Set (ES2015+)
// ── Finding: find and findIndex / findLast / findLastIndex (ES2023) ──
[1, 2, 3, 4].find(x => x > 2); // 3 (first match)
[1, 2, 3, 4].findIndex(x => x > 2); // 2 (index of first match)
[1, 2, 3, 4].findLast(x => x > 2); // 4 (last match, ES2023)
[1, 2, 3, 4].findLastIndex(x => x > 2); // 3 (index of last match, ES2023)
// ── Includes vs indexOf (NaN handling) ──
[1, 2, NaN].includes(NaN); // true (includes finds NaN!)
[1, 2, NaN].indexOf(NaN); // -1 (indexOf can't find NaN — uses ===)
// ── at() for negative indexing (ES2022) ──
[1, 2, 3].at(-1); // 3 (last element — cleaner than arr[arr.length - 1])
[1, 2, 3].at(-2); // 2
// ── toSorted / toReversed / toSpliced / with (non-mutating, ES2023) ──
const arr = [3, 1, 2];
arr.toSorted((a, b) => a - b); // [1, 2, 3] — new array, original unchanged
arr.toReversed(); // [2, 1, 3] — new array
arr.with(0, 99); // [99, 1, 2] — new array with index 0 replaced
// These are the non-mutating alternatives to sort/reverse/splice/[i]=
⚠️ Edge Cases & Gotchas
javascript
// ── Holes are skipped by map/forEach/filter (unlike undefined) ──
[1, , 3].map(x => x * 2); // [2, <empty>, 6] (hole preserved, not processed)
[1, undefined, 3].map(x => x * 2); // [2, NaN, 6] (undefined * 2 = NaN)
// ── .sort() converts to string by default (lexicographic, not numeric) ──
[10, 2, 1].sort(); // [1, 10, 2] — "10" < "2" (string comparison)
// Always pass a comparator for numeric sort: .sort((a, b) => a - b)
// ── .sort() MUTATES the original array ──
const arr = [3, 1, 2];
const sorted = arr.sort(); // arr is now [1, 2, 3] (mutated!)
// Use [...arr].sort() for a non-mutating sort (or arr.toSorted() in ES2023)
// ── .includes() finds NaN, .indexOf() doesn't ──
[NaN].includes(NaN); // true (uses SameValueZero algorithm)
[NaN].indexOf(NaN); // -1 (uses strict equality ===, NaN !== NaN)
// ── .splice() mutates and returns removed elements ──
const arr = [1, 2, 3, 4];
const removed = arr.splice(1, 2, "a", "b"); // removes [2, 3], inserts ["a", "b"]
console.log(removed); // [2, 3] (removed elements)
console.log(arr); // [1, "a", "b", 4] (mutated)
// ── Array(n) creates holes, not undefined values ──
Array(3).map(x => 0); // [empty × 3] — map skips holes, does nothing!
Array(3).fill(0).map(x => x + 1); // [1, 1, 1] — fill first, then map
🧠 Quick Quiz
What does this output?
javascript
const arr = [1, , 3];
console.log(arr.map(x => x * 2));
console.log(arr.filter(() => true));
console.log([...arr]);
console.log(arr.forEach(() => {}));
console.log(arr.indexOf(undefined));
Answer
[2, <empty>, 6] // map: skips holes (index 1 is a hole, not visited, preserved in output)
[1, 3] // filter: skips holes (only non-hole elements pass through)
[1, undefined, 3] // spread: converts holes to undefined
undefined // forEach: returns undefined (always); visits 1 and 3, skips hole at 1
-1 // indexOf: undefined is not at any index (the hole is NOT undefined)
The key insight: holes (empty slots from [1, , 3]) are NOT the same as undefined. Holes are missing properties — they have no value at all. Array methods like map, forEach, filter, and reduce skip holes entirely (don't call the callback for that index). But undefined is a real value that methods do process.
Spread ([...arr]) and Array.from() convert holes to undefined (they iterate using the array iterator, which yields undefined for holes).
indexOf(undefined) returns -1 because the hole has no value — it's not undefined in the array, it's a missing property. Use arr.includes(undefined) if you want to find holes (it returns true for [1, , 3]).