405 words
2 minutes

JavaScript toSorted Turns Array Holes into undefined: Keep Empty Slots

2026-11-07
Frontend
JavaScript
/
Node.js
/
Troubleshooting
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, 3
spread + sort: map visits 0, 1, 2, 3
slice + sort: map visits 0, 1, 2

All 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:

JavaScript toSorted Turns Array Holes into undefined: Keep Empty Slots
https://laplusda.com/en/posts/javascript-tosorted-sparse-array-holes/
Author
Zero
Published at
2026-11-07
License
CC BY-NC-SA 4.0