TypeScript ·
Array.sort() puts 110 before 25 - the default comparator trap
JavaScript's Array.sort() without a comparator sorts numbers as strings, so 110 comes before 25. Why the default works that way, and the one-line fix.
Test yourself before reading - the write-up below gives it away.
// Show the three cheapest deals, lowest price first.
function cheapestDeals(prices) {const sorted = [...prices].sort();
return sorted.slice(0, 3);
}
const prices = [110, 25, 9, 80, 5];
console.log(cheapestDeals(prices));
// expected: [ 5, 9, 25 ]
// actual: [ 110, 25, 5 ] (!)
Sorting a list of prices should be the safe part of the function. This one sorts five prices to show the cheapest first, and calls 110 cheaper than 25.
// Show the three cheapest deals, lowest price first.
function cheapestDeals(prices) {
const sorted = [...prices].sort();
return sorted.slice(0, 3);
}
const prices = [110, 25, 9, 80, 5];
console.log(cheapestDeals(prices));
// expected: [ 5, 9, 25 ]
// actual: [ 110, 25, 5 ] (!)
Why does JavaScript sort() put 110 before 25?
Array.prototype.sort() with no comparator converts every element to a string and
compares by UTF-16 code units, so "110" sorts before "25" and the numbers come out
in lexicographic, not numeric, order. Pass a numeric comparator to fix it:
sort((a, b) => a - b).
Copy the array, sort it, take the first three. The spread on line 3 even shows good
instincts - it avoids mutating the caller's array, which is sort()'s other famous
gotcha. That copy is the decoy here. The bug is the call right next to it: .sort()
with no arguments.
The bug: sort() compares strings by default
When you call Array.prototype.sort() without a comparator, JavaScript does not look
at your elements and guess. The spec (ECMA-262, SortCompare) says: convert both
elements to strings and compare them by UTF-16 code units. Every time, for every
element type - including numbers.
So [110, 25, 9, 80, 5] is compared as "110", "25", "9", "80", "5".
String-wise, "110" comes before "25" because the character "1" sorts before
"2", and "9" lands last because "9" is the largest leading character. The
"cheapest three deals" become 110, 25 and 5.
The cruel part is when it stays hidden. Sort single-digit prices - [3, 1, 2] - and
string order happens to equal numeric order. Tests with small fixtures pass. The bug
waits for the first price that crosses a digit boundary, which in production is
usually the first interesting one.
TypeScript does not save you. sort() accepts an optional comparator, so calling it
with none is perfectly well-typed. This is a semantic default, invisible to the type
checker.
Why the default is strings
Historical baggage with a logic to it: sort() predates generics-aware thinking in
the language, and a single default had to work for arrays of anything - numbers,
strings, objects, undefined holes. String conversion is the one comparison defined
for everything. The committee has kept it ever since, because changing it would break
the web. The question "why does JavaScript sort 10 before 9" has been asked on Stack
Overflow continuously since 2010; the default outlives every wave of new developers.
The fix
Pass a numeric comparator:
const sorted = [...prices].sort((a, b) => a - b);
a - b returns negative when a is smaller, positive when larger - exactly the
contract sort() expects. For descending order use b - a. If the values can be
non-finite or NaN, be explicit instead of clever: filter first or compare with
Number.isNaN guards, because NaN comparisons make the sort order unspecified.
Two habits worth keeping:
- Never call bare
.sort()on anything that is not deliberately an array of strings. Even then, considerlocaleCompareorIntl.Collatorfor human-visible text. - Keep the
[...arr]copy. Since this snippet already does, note that newer runtimes offertoSorted(), which copies and sorts in one call.
Related bugs
The same "compiles fine, misbehaves at runtime" family includes the shared-reference
trap in Array(n).fill([]) - a different
API, the same lesson that JavaScript defaults optimize for "runs without throwing",
not "does what it looks like". Its closest cousin is
map(parseInt) turning the index into a radix -
another built-in doing faithfully what nobody asked. Numbers go wrong a quieter way
when 0.1 + 0.2 refuses to equal 0.3 and a
strict === fails on the rounding. Timing goes wrong the same runtime way when
an async forEach returns 0 before its awaits settle.
Browse the rest of the bug write-ups.