OneBug All write-ups

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.

Spot the bug TypeScript
1
// Show the three cheapest deals, lowest price first.
2
function cheapestDeals(prices) {
3
  const sorted = [...prices].sort();
4
  return sorted.slice(0, 3);
5
}
6
 
7
const prices = [110, 25, 9, 80, 5];
8
console.log(cheapestDeals(prices));
9
// expected: [ 5, 9, 25 ]
10
// actual:   [ 110, 25, 5 ]  (!)
Powered by OneBug

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, consider localeCompare or Intl.Collator for human-visible text.
  • Keep the [...arr] copy. Since this snippet already does, note that newer runtimes offer toSorted(), 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.