959 words
5 minutes

JavaScript sort Boolean Comparator: Test and Fix Wrong Order

2026-10-10
Frontend
JavaScript
/
Node.js
/
Troubleshooting

numbers.sort((a, b) => a > b) can leave a numeric array out of order. The callback returns a boolean, but JavaScript sorting expects a comparison result with three meanings: before, equivalent, or after. An already sorted input can hide the defect.

For an ordinary array of finite numbers, use (a, b) => a - b for ascending order. Use numbers.toSorted((a, b) => a - b) when the source array must retain its original order. Changing from sort to toSorted alone does not repair a broken comparator.

Check both directions of the comparison#

The ECMAScript comparison algorithm converts a callback’s result to a number. Thus false becomes 0 and true becomes 1; neither result is negative.

For ascending order, a negative result places a before b, zero treats them as equivalent for sorting, and a positive result places a after b. The magnitude need not be exactly one.

Compare the same pair in both directions:

CallbackResult for (1, 2)Result for (2, 1)Contract check
(a, b) => a > bfalse, converted to 0true, converted to 1Inconsistent: equivalent in one direction, ordered in the other
(a, b) => a - b-11Opposite signs

MDN’s comparator rules require these signs to oppose each other, or both to be zero. Comparisons also need consistent results, reflexivity, and transitivity. A callback should not change the compared values or external state.

Wrapping a > b in Number(...) does not fix it: the result still has only zero and positive values. Sorting stability does not fix it either. The specification leaves the order implementation-defined when the comparator is inconsistent. Do not depend on one browser’s apparent success.

Reproduce the defect across input orders#

Save this as sort-contract.mjs and run node sort-contract.mjs. The exact fixture was executed during article preparation on macOS with Node.js 24.13.0 on October 10, 2026. It requires no packages.

The fixture checks comparison signs and transitivity on a small sample, tries all six permutations of three distinct numbers, and verifies the source-array behavior. This is a regression check for these inputs, not a proof for every possible value or custom comparator.

import assert from 'node:assert/strict';
const bad = (a, b) => a > b;
const ascending = (a, b) => a - b;
const sign = (compare, a, b) => Math.sign(Number(compare(a, b)));
function checkSample(compare, values) {
for (const a of values) {
assert.equal(sign(compare, a, a), 0, 'reflexivity');
for (const b of values) {
const ab = sign(compare, a, b);
const ba = sign(compare, b, a);
assert.ok(Number.isFinite(ab) && Number.isFinite(ba), 'numeric result');
assert.ok(ab === -ba, 'opposite signs or both zero');
for (const c of values) {
if (ab <= 0 && sign(compare, b, c) <= 0) {
assert.ok(sign(compare, a, c) <= 0, 'transitivity');
}
}
}
}
}
assert.equal(Number(bad(1, 2)), 0);
assert.equal(Number(bad(2, 1)), 1);
assert.throws(() => checkSample(bad, [1, 2, 3]), /opposite signs/);
checkSample(ascending, [-2, 0, 1, 2, 3]);
console.log('boolean comparator rejected; numeric comparator passed sample');
const inputs = [
[1, 2, 3], [1, 3, 2], [2, 1, 3],
[2, 3, 1], [3, 1, 2], [3, 2, 1],
];
let failures = 0;
for (const input of inputs) {
const broken = input.toSorted(bad);
const fixed = input.toSorted(ascending);
if (JSON.stringify(broken) !== '[1,2,3]') failures++;
assert.deepEqual(fixed, [1, 2, 3]);
console.log(`${JSON.stringify(input)} => boolean ${JSON.stringify(broken)}; numeric ${JSON.stringify(fixed)}`);
}
assert.ok(failures > 0);
console.log(`boolean failures: ${failures}/${inputs.length}`);
assert.deepEqual([10, 2, 1].toSorted(), [1, 10, 2]);
assert.deepEqual([10, 2, 1].toSorted(ascending), [1, 2, 10]);
assert.deepEqual([2, 0, -2, 2].toSorted(ascending), [-2, 0, 2, 2]);
const source = [3, 1, 2];
const copied = source.toSorted(ascending);
assert.deepEqual(source, [3, 1, 2]);
assert.notEqual(copied, source);
const returned = source.sort(ascending);
assert.equal(returned, source);
assert.deepEqual(source, [1, 2, 3]);
console.log('toSorted preserves source; sort returns and mutates source');
console.log('all assertions passed');

Output in the checked environment:

boolean comparator rejected; numeric comparator passed sample
[1,2,3] => boolean [1,2,3]; numeric [1,2,3]
[1,3,2] => boolean [1,3,2]; numeric [1,2,3]
[2,1,3] => boolean [2,1,3]; numeric [1,2,3]
[2,3,1] => boolean [2,3,1]; numeric [1,2,3]
[3,1,2] => boolean [3,1,2]; numeric [1,2,3]
[3,2,1] => boolean [3,2,1]; numeric [1,2,3]
boolean failures: 5/6
toSorted preserves source; sort returns and mutates source
all assertions passed

The boolean callback passes the already sorted case and fails the other five here. That failure count is an observed Node result, not a cross-engine guarantee. The pairwise contract check identifies the defect without relying on the sorting algorithm’s call order.

Choose the result’s ownership separately#

sort() reorders the original array and returns that same array. Assigning its return value to another variable does not make a copy. toSorted() returns a new array.

For the dense numeric arrays used here:

RequirementExpression
Reorder the source in ascending ordernumbers.sort((a, b) => a - b)
Preserve the source ordernumbers.toSorted((a, b) => a - b)
Preserve the source where toSorted is unavailable[...numbers].sort((a, b) => a - b)
Return a descending copynumbers.toSorted((a, b) => b - a)

A copied array does not deep-clone objects stored inside it. If you adapt this to records, sorting the copy and editing a record are separate operations.

Keep the numeric boundary explicit#

Omitting the comparator is a different error: ordinary arrays use string comparison by default, so the fixture verifies [10, 2, 1] becoming [1, 10, 2]. It also checks negative values, zero, and duplicate numbers with the numeric callback.

This fix assumes finite numbers, not numeric strings, NaN, undefined, sparse arrays, or mixed data. The sample checker does not validate your application’s input schema. Check or normalize incoming values before sorting, with an explicit policy for missing and invalid fields. A callback result of NaN is treated as zero by the specification, so subtracting an invalid value can silently turn a comparison into equivalence.

If the numbers come from repeated URL query fields, preserve those fields before converting the query to an object, then parse and validate their values. Sorting cannot recover values discarded earlier.

The published fixture was not run in browsers, a framework component, or older Node versions. In your target runtime, check toSorted availability and run the regression fixture. Keep the comparator’s sign contract and the source-array ownership requirement as separate checks.

References:

JavaScript sort Boolean Comparator: Test and Fix Wrong Order
https://laplusda.com/en/posts/javascript-sort-boolean-comparator/
Author
Zero
Published at
2026-10-10
License
CC BY-NC-SA 4.0