JavaScript toSorted Turns Array Holes into undefined: Keep Empty Slots
Contents
Switching a sparse array from sort() to toSorted() can make a later map() call visit extra entries. For an ordinary array, toSorted() turns empty slots into explicit undefined entries in its result. If your code relies on map() skipping those slots, use source.slice().sort(compare). This leaves the source unchanged and moves the copied holes to the end.
Check whether the index exists
In [30, , undefined, 2], both index 1 and index 2 read as undefined. Only index 2 is an own property. Use Object.hasOwn() to distinguish an absent property from one holding undefined. Reading the value or checking the array length misses the difference.
The ECMAScript sorting algorithm lets sort() skip absent indexes and lets toSorted() read through them. On ordinary arrays without inherited numeric properties, slice() copies the present indexes and leaves the holes in place before sort() runs. Array spread reads the holes as undefined during the copy, so [...source].sort(compare) already has explicit entries by the time sorting begins. ECMAScript sorting algorithm
Verify which entries reach the next callback
Save the fixture as sparse-sort.mjs and run node sparse-sort.mjs. It was executed during article preparation on November 7, 2026, with Node.js 24.13.0 on macOS. The input uses ordinary array slots, finite numbers, and one explicit undefined.
import assert from 'node:assert/strict';
const source = [30, , undefined, 2];const ascending = (a, b) => a - b;const cases = [ ['toSorted', source.toSorted(ascending), [0, 1, 2, 3]], ['spread + sort', [...source].sort(ascending), [0, 1, 2, 3]], ['slice + sort', source.slice().sort(ascending), [0, 1, 2]],];
for (const [label, result, expectedIndexes] of cases) { const visited = []; result.map((_, index) => visited.push(index));
assert.notEqual(result, source); assert.equal(result.length, 4); assert.deepEqual(result.slice(0, 2), [2, 30]); assert.equal(result[2], undefined); assert.equal(result[3], undefined); assert.equal(Object.hasOwn(result, 3), expectedIndexes.includes(3)); assert.deepEqual(visited, expectedIndexes);
console.log(`${label}: map visits ${visited.join(', ')}`);}
assert.deepEqual(source, [30, , undefined, 2]);assert.equal(Object.hasOwn(source, 1), false);assert.equal(Object.hasOwn(source, 2), true);toSorted: map visits 0, 1, 2, 3spread + sort: map visits 0, 1, 2, 3slice + sort: map visits 0, 1, 2All three results have the same length and start with [2, 30]. Only the slice().sort() result leaves index 3 empty. Index 2 still holds the original explicit undefined, so map() calls its callback there in all three cases.
Choose toSorted() when every output position should be present. Choose slice().sort(compare) when downstream code must distinguish empty slots from explicit values. The latter preserves the number of holes. If the original positions carry meaning, keep the source indexes with the entries before sorting.
References: