GUIDE · JavaScript

Sorting (comparators)

lesson10 sectionsruns in browser22 asserts75 lines

At a glance

RULE
sort() with NO comparator converts to STRINGS. Always pass one.
MUTATES
sort() and reverse() change the array in place and return it.
STABLE
since ES2019 (TimSort). Equal elements keep input order.
SAY
comparatorstable sortmulti-key / tie-breakerO(n log n)sort then slice for top-NlocaleCompare for strings

The idea#

Think of sort like a librarian filing books by the letters printed on the spine. Hand over volumes 10, 2, and 1. The librarian reads the labels as words: "1", then "10", then "2". Volume 10 lands between volume 1 and volume 2 because the character "1" comes before the character "2". Nobody asked which number is larger. The default question is which string comes first.

That default is the whole bug. sort() with no comparator converts each element to a string and orders those strings by UTF-16 code units. Lexicographic order. The fix is a comparator, a function (a, b) you pass in, the rule card on the cart. Return a negative number and a goes on the shelf first. Return a positive number and b goes first. Return 0 and those two keep the order they already had. For numbers, (a, b) => a - b is ascending. (a, b) => b - a is descending.

The librarian rearranges the shelf you handed them. sort and reverse mutate the array and return that same array. If the arrival order still matters, copy first with [...books].sort(cmp). On Node 20 and later, toSorted does the copy inside the method. Since ES2019 the algorithm is a stable sort, TimSort. Equal elements keep their input order. Two books with the same call number do not trade places. The cost of a full sort is O(n log n).

Why it matters#

Single-digit samples hide this. The shelf goes wrong the moment a 10 shows up, while the small tests still pass. Any time you order scores, prices, indexes, or timestamps, pass a comparator.

Strings have a second spine problem. Default sort uses code units, so 'C' files before 'a', because uppercase letters sit earlier in the chart. localeCompare asks the language for the order a reader expects. Pass { numeric: true } and 'item2' files before 'item10'. A hand-rolled a < b test is back to code units. Use it when you mean code units. Use localeCompare when you mean words.

Real shelves have more than one label. A leaderboard is score descending, then name ascending when the scores tie. Chain the comparisons with ||. A comparator result of 0 is falsy, so the next key runs only on a tie. That is a multi-key sort, a tie-breaker. If the key is expensive, compute it once. Map each word to a note { w, k: w.length }, sort the notes, then map back to the words. That decorate-sort-undecorate pass is the Schwartzian transform.

Booleans sort after Number turns them into 0 and 1. A custom order is a lookup such as { high: 0, medium: 1, low: 2 }. Map entries are pairs, so you sort [key, value] the same way. Top-N is a sort plus slice. The k-th smallest is an index into that sorted copy. A linear quickselect is the name to remember if someone asks for the element without a full sort. Numbers stored as strings need Number(a) - Number(b). Subtraction already coerces, so a mix of 3 and '2' lands in numeric order under (a, b) => a - b. Asking whether a list is already sorted is one forward pass with every.

A small example#

JS
const arrivals = [10, 2, 1]; function byNumber(a, b) {  return a - b;} const bySpine = [...arrivals].sort();const bySize = [...arrivals].sort(byNumber);

bySpine is [1, 10, 2]. The librarian read the spines as words. bySize is [1, 2, 10], because byNumber is the rule card: negative means a first. arrivals is still [10, 2, 1]. The sort ran on a copy. Hand the original array to sort and the cart itself gets rearranged.

What the code below is doing#

The code below is ten sections, and the first one is the bug.

Numbers show the string sort of [10, 9, 1, 100], then ascending and descending comparators, and the contract: negative, positive, zero. The copy section spreads before sorting, and the comment names toSorted on engines that have it. Strings show code-unit order on 'b', 'a', and 'C', then localeCompare, an explicit < comparison, and numeric localeCompare so 'item2' precedes 'item10'.

Multi-key sorting chains with ||: score descending, then name ascending, then a fresh sort by age. The people array is mutated by those sorts, which is why the copy lesson sits above them. Derived keys decorate each word with its length, sort, and undecorate. Booleans, a priority lookup, and Map entries sorted by count then key come next. Top-N takes the first two after a descending sort. The second-smallest is index 1 of an ascending sort. Stability sorts { k, v } records and keeps 'x' ahead of 'z' when the keys tie. Stringy numbers and a mixed array go through subtraction. The last section is isSorted, one every pass, true for [1, 2, 2, 3] and false for [2, 1].

The trap the header names as the rule: sort() with no comparator converts every value to a string. Always pass one. sort and reverse also change the array in place and return it. Stable since ES2019 means ties keep input order. Say comparator, stable sort, multi-key, tie-breaker, O(n log n), sort-then-slice for top-N, and localeCompare for strings.

Everything below is the demonstration. Same idea, written as code you can run.

JSjavascript/sorting.js · setup
'use strict';const assert = require('node:assert/strict');
01

Numbers#

JS
// comparator contract: negative -> a first, positive -> b first, 0 -> keep order
Verified by 3 asserts
JS
assert.deepEqual([10, 9, 1, 100].sort(), [1, 10, 100, 9]);                 // WRONG: "1" < "10" < "100" < "9"assert.deepEqual([10, 9, 1, 100].sort((a, b) => a - b), [1, 9, 10, 100]);  // ascendingassert.deepEqual([10, 9, 1, 100].sort((a, b) => b - a), [100, 10, 9, 1]);  // descending
02

Don't destroy the input#

JS
const original = [3, 1, 2];const sorted = [...original].sort((a, b) => a - b);          // Node 20+: original.toSorted(cmp)
Verified by 2 asserts
JS
assert.deepEqual(original, [3, 1, 2]);assert.deepEqual(sorted, [1, 2, 3]);
03

Strings#

Verified by 4 asserts
JS
assert.deepEqual(['b', 'a', 'C'].sort(), ['C', 'a', 'b']);                        // char codes: uppercase firstassert.deepEqual(['b', 'a', 'C'].sort((x, y) => x.localeCompare(y)), ['a', 'b', 'C']);assert.deepEqual(['b', 'a', 'C'].sort((x, y) => (x < y ? -1 : x > y ? 1 : 0)), ['C', 'a', 'b']);  // explicit ASCIIassert.deepEqual(['item10', 'item2'].sort((x, y) => x.localeCompare(y, undefined, { numeric: true })), ['item2', 'item10']);
04

Multi-key with tie-breakers: chain with || (0 is falsy, so it falls through)#

JS
const people = [  { name: 'bo', score: 90, age: 30 },  { name: 'al', score: 90, age: 25 },  { name: 'cy', score: 95, age: 40 },];people.sort((p, q) => q.score - p.score || p.name.localeCompare(q.name));   // score desc, then name ascpeople.sort((p, q) => p.age - q.age);
Verified by 2 asserts
JS
assert.deepEqual(people.map(p => p.name), ['cy', 'al', 'bo']);assert.deepEqual(people.map(p => p.name), ['al', 'bo', 'cy']);
05

Sort by a derived key (compute once: "Schwartzian transform" / decorate-sort-undecorate)#

JS
const words = ['banana', 'fig', 'apple'];const byLenThenAlpha = words  .map(w => ({ w, k: w.length }))  .sort((x, y) => x.k - y.k || x.w.localeCompare(y.w))  .map(x => x.w);
Verified by 1 assert
JS
assert.deepEqual(byLenThenAlpha, ['fig', 'apple', 'banana']);
06

Booleans / custom order / Map entries#

JS
const priority = { high: 0, medium: 1, low: 2 };const counts = new Map([['b', 2], ['a', 2], ['c', 5]]);
Verified by 3 asserts
JS
assert.deepEqual([true, false, true].sort((x, y) => Number(y) - Number(x)), [true, true, false]);assert.deepEqual(['low', 'high', 'medium'].sort((x, y) => priority[x] - priority[y]), ['high', 'medium', 'low']);assert.deepEqual([...counts].sort(([k1, v1], [k2, v2]) => v2 - v1 || k1.localeCompare(k2)), [['c', 5], ['a', 2], ['b', 2]]);
07

Top-N / Kth#

Verified by 2 asserts
JS
assert.deepEqual([5, 1, 4, 2, 3].sort((a, b) => b - a).slice(0, 2), [5, 4]);assert.equal([5, 1, 4, 2, 3].sort((a, b) => a - b)[1], 2);   // 2nd smallest (quickselect is O(n) if asked)
08

Stability demo#

JS
const stable = [{ k: 1, v: 'x' }, { k: 0, v: 'y' }, { k: 1, v: 'z' }].sort((a, b) => a.k - b.k);
Verified by 1 assert
JS
assert.deepEqual(stable.map(o => o.v), ['y', 'x', 'z']);      // x stays before z
09

Sorting numbers stored as strings / mixed#

Verified by 2 asserts
JS
assert.deepEqual(['10', '9', '1'].sort((a, b) => Number(a) - Number(b)), ['1', '9', '10']);assert.deepEqual([3, '2', 1].sort((a, b) => a - b), [1, '2', 3]);  // coercion in subtraction, fine here
10

Check "is sorted" in one pass#

JS
const isSorted = xs => xs.every((x, i) => i === 0 || xs[i - 1] <= x);
Verified by 2 asserts
JS
assert.equal(isSorted([1, 2, 2, 3]), true);assert.equal(isSorted([2, 1]), false);

See also