Sorting
Mental model: A comparator answers one question: should a come before b? Negative means yes, positive means no, zero means leave them as they are.
Level: intermediate · about 12 minutes
console.log([10, 9, 100].sort());
// [10, 100, 9]
console.log([10, 9, 100].sort((a, b) => a - b));
// [9, 10, 100]Run this before you read on. The output is not a typo.
You may have heard that sort sorts numbers. It does not. **The default sort converts every element to a string and compares those strings**, so "100" sorts before "9" for the same reason "apple" sorts before "banana". Any numeric sort needs a comparator.
How a comparator works
compare(a, b) returns | Meaning | Result |
|---|---|---|
| a negative number | a should come first | a, b |
| a positive number | b should come first | b, a |
0 | they are equal for sorting | original order kept |
const nums = [5, 1, 4];
console.log([...nums].sort((a, b) => a - b)); // [1, 4, 5] ascending
console.log([...nums].sort((a, b) => b - a)); // [5, 4, 1] descending
console.log(nums); // [5, 1, 4] (untouched, because we copied)Subtraction is the whole trick for numbers.
Sorting objects by a field
const people = [
{ name: 'Grace', age: 45 },
{ name: 'Ada', age: 36 },
{ name: 'Alan', age: 41 },
];
const byAge = [...people].sort((a, b) => a.age - b.age);
console.log(byAge.map((p) => p.name)); // ['Ada', 'Alan', 'Grace']
const byName = [...people].sort((a, b) => a.name.localeCompare(b.name));
console.log(byName.map((p) => p.name)); // ['Ada', 'Alan', 'Grace']
For strings, localeCompare returns the negative, zero or positive number a comparator wants, and it knows that "a" and "A" belong together and that "é" sorts near "e". Comparing with < and > compares UTF-16 code units instead, which puts every capital letter before every lowercase one.
const words = ['banana', 'Apple', 'cherry'];
console.log([...words].sort()); // ['Apple', 'banana', 'cherry']
console.log([...words].sort((a, b) => (a < b ? -1 : 1))); // ['Apple', 'banana', 'cherry']
console.log([...words].sort((a, b) => a.localeCompare(b))); // ['Apple', 'banana', 'cherry']
console.log(['résumé', 'resume', 'zoo'].sort((a, b) => a.localeCompare(b)));
// ['resume', 'résumé', 'zoo'], accents stay next to their base letter
const files = ['item10', 'item2', 'item1'];
console.log([...files].sort((a, b) => a.localeCompare(b)));
// ['item1', 'item10', 'item2'], string order
const natural = new Intl.Collator('en', { numeric: true });
console.log([...files].sort(natural.compare));
// ['item1', 'item2', 'item10'], what a human expects
Multi-key sort: fall through on a tie
const staff = [
{ dept: 'infra', name: 'Alan' },
{ dept: 'core', name: 'Grace' },
{ dept: 'infra', name: 'Ada' },
{ dept: 'core', name: 'Ada' },
];
const sorted = [...staff].sort(
(a, b) => a.dept.localeCompare(b.dept) || a.name.localeCompare(b.name)
);
console.log(sorted.map((s) => s.dept + '/' + s.name));
// ['core/Ada', 'core/Grace', 'infra/Ada', 'infra/Alan']Return the first non-zero comparison. || does that for you.
const rows = [
{ team: 'b', score: 1 },
{ team: 'a', score: 2 },
{ team: 'b', score: 2 },
];
const stable = [...rows]
.sort((x, y) => x.team.localeCompare(y.team)) // secondary first
.sort((x, y) => y.score - x.score); // primary last
console.log(stable.map((r) => r.team + r.score)); // ['a2', 'b2', 'b1']
toSorted: the copying version
Mutating (sort)
const original = [3, 1, 2];
const same = original.sort((a, b) => a - b);
console.log(original); // [1, 2, 3]
console.log(same === original); // true
Copying (toSorted)
const original = [3, 1, 2];
const copy = original.toSorted((a, b) => a - b);
console.log(original); // [3, 1, 2]
console.log(copy); // [1, 2, 3]toSorted takes the same comparator and returns a new array. It arrived in ES2023, and in state management code it removes a whole category of "why did my props change?" bugs.
const ages = [8, 10, 9];
console.log(ages.sort((a, b) => a > b));The comparator returns a boolean, which becomes 1 or 0, never a negative number, so the engine is never told to move an element left. With this input nothing swaps and the array comes back in its original order. Always return a - b.
Try it yourself
Comparator workshop
const players = [
{ name: 'ada', score: 30 },
{ name: 'Alan', score: 42 },
{ name: 'grace', score: 30 },
{ name: 'bob', score: 42 },
];
const byScoreThenName = [...players].sort(
(a, b) => b.score - a.score || a.name.localeCompare(b.name)
);
console.log(byScoreThenName.map((p) => p.name + ':' + p.score));
console.log('original order kept:', players.map((p) => p.name));
Sort by score descending, then by name ascending as the tie-break. Then swap localeCompare for < and find an input where the difference shows.
Exercises
sortByThenBy
Write sortByThenBy(items, primary, secondary) that returns a new array sorted ascending by the primary property, using the secondary property to break ties. It must work for both numbers and strings, and must not mutate the input.
Check yourself
- What does this log?
- [1, 10, 20, 5] — Without a comparator the elements are converted to strings and compared character by character, so
"10"and"20"both sort before"5". This is the most common array bug in production JavaScript. - Your comparator returns
a.score > b.score. What goes wrong? - It never returns a negative number, so elements are never moved left —
truebecomes1andfalsebecomes0, so the engine only ever hears "b first" or "these are equal". The output can look correct for tiny arrays and be wrong for larger ones, which makes it a nasty bug to spot. - You need to sort a list for display without disturbing the array held in your store. What do you call?
list.toSorted(cmp)—toSortedreturns a new sorted array and leaves the original alone.[...list].sort(cmp)is the equivalent older spelling. Callingsortdirectly reorders the stored array, which is how UI state silently drifts.
Common mistakes
- Calling
sort()with no comparator on numbers. - Returning a boolean from the comparator instead of a number.
- Forgetting that
sortmutates and returns the same array, not a copy.
Takeaways
- The default sort is lexicographic. Numbers always need
(a, b) => a - b. - A comparator returns negative, zero or positive. Never a boolean.
- Chain comparisons with
||for multi-key sorts, and lean on guaranteed stability. localeCompare(or a reusedIntl.Collator) for human-facing string order;toSortedwhen you need a copy.