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) returnsMeaningResult
a negative numbera should come firsta, b
a positive numberb should come firstb, a
0they are equal for sortingoriginal 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 — true becomes 1 and false becomes 0, 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) — toSorted returns a new sorted array and leaves the original alone. [...list].sort(cmp) is the equivalent older spelling. Calling sort directly 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 sort mutates 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 reused Intl.Collator) for human-facing string order; toSorted when you need a copy.