A product table sorted with (a, b) => a.price - b.price shows a few rows in obviously wrong positions on the full dataset, though a short test list sorts fine. How do you diagnose and fix it?
answer
- wrong only for some rows
- small input hides it
- the subtraction is not a number
- NaN comparison counts as equal
- inconsistent comparator, implementation-defined order
basics
~20 sSome price values are not numbers, so the subtraction yields NaN, which sort treats as zero. Those rows compare equal to everything, making the comparator inconsistent and the order implementation-defined. Normalise prices to numbers and give missing values an explicit rank.
solid answer
~50 sThe comparator is producing `NaN`. If any `price` is `null`, `undefined`, or a string like `"1,299"`, then `a.price - b.price` is `NaN`, and the sorting operation converts a `NaN` comparison result to `+0` — so those rows report "equal" against every row they meet. That makes the comparison non-transitive, and the spec says the resulting order is implementation-defined, which is why the damage is not confined to the bad rows. It passes on short lists because engines may take a different internal path for small arrays. To diagnose: instrument the comparator to log any pair where `Number.isNaN(result)` is true, or scan the data with `rows.filter(r => typeof r.price !== 'number' || Number.isNaN(r.price))`. To fix: normalise each price to a number once, up front, and decide explicitly where missing values belong — for example `(a, b) => rank(a) - rank(b) || a.id - b.id`, with missing prices mapped to `Infinity` so they sort last deterministically.
code
javascript · 20 linesconst rows = [
{ id: 1, price: 30 },
{ id: 2, price: null },
{ id: 3, price: 10 },
{ id: 4, price: '1,299' },
{ id: 5, price: 20 },
];
// Broken: NaN for any pair involving id 2 or id 4.
console.log([...rows].sort((a, b) => a.price - b.price).map(r => r.id));
const priceOf = (r) => {
const n = typeof r.price === 'number'
? r.price
: Number(String(r.price).replace(/,/g, ''));
return Number.isFinite(n) ? n : Infinity; // unpriced rows last
};
const byPrice = (a, b) => priceOf(a) - priceOf(b) || a.id - b.id;
console.log([...rows].sort(byPrice).map(r => r.id)); // [3, 5, 1, 4, 2]go deeper
Know that subtracting non-numeric fields gives NaN and that sort quietly treats a NaN result as "equal". Check the data types of the sort key before suspecting the sort itself.
Explain why the damage is not local: a value that compares equal to everything breaks transitivity, so the specification leaves the entire ordering implementation-defined. Be able to describe how to instrument the comparator to catch it.
Show the full diagnosis-to-fix path: reproduce on real data, audit the comparator's return values, normalise the key once, define an explicit position for missing values, and add a unique tiebreak so the result is reproducible.
Push it upstream: decide whether the ordering contract lives in the API, and require that any sort key crossing a boundary be a normalised, non-null field with a documented total order — so client and server can never disagree about page boundaries.
## Reading the symptom Two details in the report point straight at the cause. First, the misplacement is *partial* — most rows are ordered correctly, a few are not. A comparator that is simply backwards or compares the wrong field would be wrong everywhere. Second, it reproduces on the full dataset but not on a short test list. Sorting is deterministic for a given comparator and input, so "works small, fails large" means the comparator's answers are not self-consistent and the engine's internal strategy is exposing that inconsistency differently at different sizes. ## The mechanism `a.price - b.price` assumes both operands are numbers. Real data disagrees: a price may be `null` for an unpriced item, `undefined` if the field was never set, or a formatted string such as `"1,299"` that fails numeric conversion. Any of these makes the subtraction `NaN`. The sorting operation converts the comparator's return value with `ToNumber` and, when the result is `NaN`, uses `+0` instead. `+0` means "these two are equivalent". So a row with a broken price is declared equal to *every* row it is compared with. That is not just a misplaced row — it destroys transitivity. If `bad` equals `10` and `bad` equals `99`, an ordering algorithm may legitimately conclude that `10` and `99` are interchangeable in some region of the array. The specification's rule is explicit: if the comparator is not a consistent comparison function, the sort order is implementation-defined. The corruption is therefore free to spread beyond the offending rows, which matches "a few rows in obviously wrong positions". ## Diagnosing it Start with the data, not the algorithm: ```js const suspects = rows.filter( r => typeof r.price !== 'number' || Number.isNaN(r.price) ); console.table(suspects); ``` If you need to catch it in place, wrap the comparator: ```js const audited = (cmp) => (a, b) => { const r = cmp(a, b); if (typeof r !== 'number' || Number.isNaN(r)) { console.warn('bad comparison', a, b, r); return 0; } return r; }; rows.sort(audited((a, b) => a.price - b.price)); ``` For a stronger guarantee, assert the contract over the data rather than eyeballing output: for sampled pairs check antisymmetry (`sign(cmp(a, b)) === -sign(cmp(b, a))`) and for sampled triples check transitivity. A property-based test that generates records including nulls and strings catches this class of bug permanently, and it is the test the original short unit test failed to be. ## Fixing it Three parts, in order of importance. **1. Normalise the key.** Convert once, before sorting, so the comparator only ever sees numbers: ```js const priceOf = (r) => { const n = typeof r.price === 'number' ? r.price : Number(String(r.price).replace(/,/g, '')); return Number.isFinite(n) ? n : Infinity; // missing prices sort last }; ``` **2. Define the missing-value policy explicitly.** "Unpriced items go last" is a product decision, not an accident of `NaN` propagation. Encoding it as `Infinity` (last) or `-Infinity` (first) makes it visible and testable. Never let missing data mean "equal to everything". **3. Make the order total.** Append a unique tiebreak so two genuinely equal prices always resolve the same way: ```js const byPrice = (a, b) => priceOf(a) - priceOf(b) || a.id - b.id; ``` With that, the output is a pure function of the data — identical between runs, between engines, and between server and client. That matters most when the list is paginated: an unstable or inconsistent order can make a row appear on two pages, or on none. ## Cost note `priceOf` now does string work. Called inside the comparator it runs on the order of n log n times. For a large table, decorate first — map each row to `{ key: priceOf(row), row }`, sort on `key`, then map back — so the parsing cost is paid once per element rather than once per comparison. ## The lesson to state out loud JavaScript reports none of this. There is no exception, no warning, no `NaN` leaking into the output — the comparator silently degrades into "everything is equal" for the affected pairs. Any comparator that touches user- or API-supplied data has to guarantee it returns a real number for every possible pair, and the tests have to exercise inputs longer and dirtier than the happy path.
- Why does the bug reproduce on the full dataset but not on a five-row test?Because the comparator is inconsistent, the spec leaves the order implementation-defined, and engines are free to use different internal strategies depending on the run's size and shape. A short array may be handled by a path that happens to produce a plausible result. The absence of failure on small inputs is not evidence of correctness.
- How would you prove the comparator is correct rather than just eyeballing the output?Test the contract, not one output. Over generated records — including nulls, strings, and duplicates — assert antisymmetry, that `sign(cmp(a, b))` is the negation of `sign(cmp(b, a))`, and transitivity across sampled triples, plus that every result is a finite number. Property-based tests catch dirty-data cases a hand-written fixture never contains.
- The list is paginated server-side. Does that change your fix?It raises the stakes. A non-total order lets a row appear on two pages or on none, because the boundary between pages depends on how ties resolve. Both sides must agree on a deterministic total order, so the unique-key tiebreak stops being a nicety and the sort definition has to match the server's ORDER BY exactly.
- The normalisation step parses strings. Does that hurt performance?It can, because the comparator runs on the order of n log n times, so per-comparison parsing is paid far more often than per-element work. Decorate first: map each row to `{ key, row }` with the key computed once, sort on `key`, then map back. That turns n log n parses into n.
saying these in an interview costs you the question
- Blaming the engine's sorting algorithm rather than the comparator
- Expecting sort to throw or warn on a NaN comparison
- Assuming only the bad rows can be misplaced
- Treating a passing five-row test as proof of correctness
- Letting missing values default to "equal to everything"