JAVASCRIPT / ARRAYS
Sorting arrays and the comparator contract
Write comparators that sort numbers, strings, and objects correctly, and predict how sort mutates arrays, handles ties, undefined, and holes.
What you will learn
- Use (a, b) => a - b for numbers; the argument-less sort compares string forms
- Read a comparator by its sign: negative keeps a first, positive keeps b first, 0 ties
- Chain keys with || and lean on stable sort so ties keep their input order
- Copy with slice or spread, or use toSorted, when the original array must survive
Understanding Sorting arrays and the comparator contract
Array.prototype.sort has no built-in notion of what your values mean, so with no argument it falls back to the one ordering that applies to every type: each element is converted with String() and the results are compared code unit by code unit. That is why [10, 9, 100].sort() gives [10, 100, 9] — the text '10' is a prefix of '100' so it sorts first, and both start with '1', which beats '9'. sort also rearranges the array it was called on and returns that same array object rather than a copy, so the return value is a convenience, not a new list.
A comparator is a two-argument function whose sign is the entire message: negative means the first argument belongs earlier, positive means the second one does, and 0 means either order is acceptable. Magnitude is ignored, which is why a - b works for numbers and why returning a boolean fails, since true and false become 1 and 0 and the 'a comes first' answer is never sent. The contract also demands consistency: swapping the arguments must flip the sign, the ordering must be transitive, and the same pair must always get the same answer. Break that and nothing throws; the algorithm skips comparisons it believes it can infer, so you get an arrangement that shifts with input size and engine.
Since ES2019 sort is required to be stable, so returning 0 means 'leave these two in the order they already had', which is exactly what makes multi-key sorting and || tie-breaking predictable. undefined elements and holes never reach the comparator at all: sort relocates them to the end, undefined values first and holes last, and only compares real values. When the original order still matters, sort a copy made with slice() or spread, or use toSorted(), the non-mutating version added in ES2023.
const nums = [10, 9, 100, 1, 25];
// No comparator: elements are compared as strings.
console.log([...nums].sort().join(' '));
// Comparator: negative means a first, positive means b first, 0 means tie.
console.log([...nums].sort((a, b) => a - b).join(' '));
console.log([...nums].sort((a, b) => b - a).join(' '));
// sort reorders the array it was called on and returns that same array.
const result = nums.sort((a, b) => a - b);
console.log(result === nums, nums.join(' '));sort knows nothing about your data beyond the sign your comparator returns, and it trusts those answers to describe one consistent ordering.
Worked examples
Ties, stability, and a second key
Shows what a 0 result actually does and how || adds a tie-breaker.
const players = [
{ name: 'Cy', score: 30 },
{ name: 'Dee', score: 50 },
{ name: 'Ada', score: 30 },
{ name: 'Bo', score: 50 }
];
const names = list => list.map(p => p.name).join(' ');
// Equal scores make the comparator return 0.
console.log(names([...players].sort((a, b) => b.score - a.score)));
// The second comparison runs only when the first returns 0.
console.log(names([...players].sort(
(a, b) => b.score - a.score || a.name.localeCompare(b.name, 'en')
)));Example explained
Line 1b.score - a.score is positive when b outranks a, which pushes b earlier and produces descending order.
Line 2Dee stays ahead of Bo in the first line because their scores tie and a stable sort preserves input order for 0 results.
Line 3The || operator forwards the name comparison only when the score difference is 0, so Bo now precedes Dee.
Line 4localeCompare already returns a negative, zero, or positive number, so it fits the comparator shape without wrapping.
Where undefined and holes end up
Demonstrates that sort relocates undefined values and holes without calling the comparator on them.
const arr = [3, undefined, 1, , 2]; // index 3 is a hole
arr.sort((a, b) => a - b);
console.log(arr.length, arr[0], arr[1], arr[2]);
console.log(arr[3], 3 in arr, 4 in arr);
console.log(JSON.stringify(arr));Example explained
Line 1a - b never sees undefined or the hole, so it cannot produce NaN here; sort filters both out before comparing.
Line 2Real values are packed at the front, then the undefined value at index 3, then the hole at index 4 — hence 3 in arr is true but 4 in arr is false.
Line 3length stays 5 because sort permutes existing slots instead of adding or removing elements.
Line 4JSON.stringify writes both the undefined and the hole as null, which hides the difference the in operator reveals.
Text order versus human order
Compares the default code-unit ordering with a locale-aware numeric comparator.
const labels = ['item10', 'item2', 'Item1'];
console.log([...labels].sort().join(' '));
console.log(
[...labels].sort((a, b) => a.localeCompare(b, 'en', { numeric: true })).join(' ')
);Example explained
Line 1The default sort compares code units, so capital I (73) beats lowercase i (105) and 'Item1' leads.
Line 2'item10' precedes 'item2' by default because the fourth character comparison is '1' against '2', with no notion of numeric value.
Line 3{ numeric: true } makes localeCompare treat digit runs as numbers, so 2 now sorts before 10.
Line 4Passing 'en' explicitly keeps the result from depending on whatever locale the runtime happens to default to.
Important notes
A comparator that returns NaN — for example a - b when one element is a non-numeric string — is treated as 0, so mixed types yield an arbitrary order rather than an error.
Do not shuffle with sort((a, b) => Math.random() - 0.5): the answers are inconsistent, so the contract is broken, the distribution is biased, and the result depends on the engine's algorithm.
Common mistakes
Sorting numbers with no comparator: [1, 10, 2, 9].sort() hands back [1, 10, 2, 9] unchanged because '10' sorts before '2', and the bug stays invisible while every value has the same digit count.
Returning a boolean, as in (a, b) => a > b: true and false coerce to 1 and 0, so the comparator can only say 'b first' or 'no preference' and the result is sorted only by accident.
Writing const sorted = data.sort(compare) and then treating data as the untouched original: sorted and data are the same object, so the pre-sort order is already lost.
Try it yourself
Change, predict, then run
Sort a copy of [{ city: 'Oslo', temp: 4 }, { city: 'Lima', temp: 19 }, { city: 'Cairo', temp: 19 }, { city: 'Perth', temp: 27 }] by temp descending with city name as the tie-breaker, then log the original array to confirm it is unchanged.
Open the JavaScript workspaceCheck your understanding
A comparator is written as (a, b) => a.age > b.age. It appears to work on a four-element array but scrambles a thirty-element one. What explains that?
- Booleans coerce to 1 and 0, so the comparator can say 'b first' or 'no preference' but never 'a first'; whether that lands correctly depends on the engine's algorithm and the input size.
- sort ignores return values that are not numbers and silently falls back to comparing the string forms of the elements.
- sort only validates comparators on arrays above the engine's insertion-sort threshold, and throws a TypeError past that size.
- Booleans are acceptable comparator results; the real problem is that sort stops being stable once the array grows large enough.
Show answer
true and false become 1 and 0, so the only messages sort ever receives are 'put b first' and 'these tie' — the negative result that means 'put a first' is unreachable, and sort builds an order from that incomplete information, which can look right on short inputs by luck. There is no string fallback (the 1 or 0 is used as given) and no validation step that could throw, so the failure is silent rather than reported; stability is guaranteed at every size and is not the issue.