Skip to content
FLAVIO COPES
flaviocopes.com

How to write JavaScript sort comparators

By

Learn how JavaScript sort comparators work: the return value contract, the string sort trap, multi-key sorting, localeCompare and Intl.Collator.

~~~

Every time you call sort() with a callback, you’re writing a comparator. It’s a tiny function with a strict contract, and getting it slightly wrong produces sorts that look right on your test data and break on real data.

I showed the basics in how to sort an array of objects by a property. This post goes deeper into the comparator itself.

The contract

A comparator takes two elements, a and b, and returns a number:

That’s the whole contract. The engine calls your function many times while sorting, and it trusts those three cases to be consistent.

For numbers, subtraction gives you all three cases for free:

const scores = [92, 88, 105, 61]
scores.sort((a, b) => a - b) //[61, 88, 92, 105]

If a is smaller, a - b is negative, so a goes first. Ascending order.

Flip the operands for descending:

scores.sort((a, b) => b - a) //[105, 92, 88, 61]

The default sort trap

Here’s the trap everyone falls into once. What does sort() do with no comparator?

const scores = [92, 88, 105, 61]
scores.sort() //[105, 61, 88, 92]

That’s not a bug. Without a comparator, sort() converts every element to a string and compares the strings. And '105' comes before '61' alphabetically, because '1' < '6'.

My advice: never call sort() without a comparator unless you’re sorting strings and you know it.

Also notice that sort() mutates the array in place. If you want a sorted copy, use toSorted():

const sorted = scores.toSorted((a, b) => a - b)

Sorting strings properly

For strings, the comparison operators technically work:

names.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))

But this compares character codes, so all uppercase letters sort before all lowercase ones, and accented characters end up in weird places. 'Zoe' < 'alice' is true.

localeCompare() fixes this. It compares strings the way a human would, and it already returns negative/zero/positive, so it slots straight into the comparator:

const names = ['alice', 'Bob', 'carol', 'Ándrea']
names.sort((a, b) => a.localeCompare(b))
//['alice', 'Ándrea', 'Bob', 'carol']

I wrote about localeCompare() in more detail, but the options argument deserves a mention here. The numeric option is the one I use most:

const files = ['file10.txt', 'file2.txt', 'file1.txt']

files.sort((a, b) => a.localeCompare(b))
//['file1.txt', 'file10.txt', 'file2.txt']

files.sort((a, b) => a.localeCompare(b, 'en', { numeric: true }))
//['file1.txt', 'file2.txt', 'file10.txt']

Without numeric: true, file10 sorts before file2 because it’s compared character by character. With it, the digits are compared as numbers.

There’s also sensitivity: 'base', which ignores case and accents, so 'alice' and 'Alice' compare as equal.

Multi-key sorting

Real sorting is usually “by score descending, then by name”. The pattern is: compare the first key, and if it’s a tie, fall through to the next key.

const players = [
  { name: 'carol', score: 88 },
  { name: 'alice', score: 92 },
  { name: 'bob', score: 88 },
]

players.sort((a, b) => {
  const byScore = b.score - a.score
  if (byScore !== 0) return byScore
  return a.name.localeCompare(b.name)
})

Each key returns early if it decides the order. Only ties reach the next comparison. You can chain as many keys as you want this way.

A common shortcut uses ||, since 0 is falsy:

players.sort((a, b) =>
  b.score - a.score || a.name.localeCompare(b.name)
)

It reads nicely for two or three keys. Beyond that, I prefer the explicit version.

Dates fit the same pattern, because subtracting dates gives you milliseconds. I showed that in how to sort an array by date.

Stability: ties keep their order

Since ES2019, sort() is guaranteed to be stable: elements that compare as equal keep their original relative order.

This matters more than it sounds. It means you can sort in passes. Sort by name first, then sort by score, and players with equal scores stay alphabetical from the first pass. It also means a comparator that returns 0 for ties is safe — the engine won’t shuffle them randomly.

Intl.Collator for performance

localeCompare() has a cost: with a locale and options, each call does locale work. In a sort over a big array, your comparator runs thousands of times.

Intl.Collator lets you pay that setup cost once:

const collator = new Intl.Collator('en', { numeric: true })

files.sort(collator.compare)

collator.compare is a ready-made comparator function. Same results as localeCompare() with the same options, noticeably faster on large arrays. For a few dozen items you won’t notice a difference, so don’t reach for it until you need it.

Watch out for null

If some objects are missing the property you’re sorting by, undefined - 5 is NaN, and returning NaN from a comparator breaks the contract. The sort result becomes unpredictable.

Handle missing values explicitly, deciding whether they go first or last:

players.sort((a, b) => {
  if (a.score == null && b.score == null) return 0
  if (a.score == null) return 1 //nulls last
  if (b.score == null) return -1
  return a.score - b.score
})

Try it interactively

Writing a multi-key comparator with null handling and locale options is exactly the kind of code you write once and copy forever. I built a sort comparator builder where you pick your keys, types, directions and null handling, see the sorted result live on your own JSON, and copy the generated comparator function.

~~~

Related posts about js: