JSE Certified Entry-Level JavaScript Programmer study materials
JSE Certified Entry-Level JavaScript Programmer study resources

Sorting and Rounding Money That Arrives as Strings

JSE Certified Entry-Level JavaScript Programmer · Put your knowledge to work in context.

All case studies

Sorting and Rounding Money That Arrives as Strings

A reporting page loads 40 products from an API as JSON where price is a string with two decimals ('9.99', '100.00', '25.50') and discount is sometimes null. The developer sorts rows with rows.sort((a, b) => a.price > b.price) and gets an order that puts '100.00' before '25.50' and visibly shuffles equal prices between runs. Line totals are computed as (price * qty).toFixed(2) and summed with +, and the grand total for a 3-line order comes out as 29.970000000000002 in one place and '29.97' in another. Finance requires totals to match to the cent and stable ordering for ties. Clicking the column header a second time is meant to reverse the order, and a Reset link is supposed to restore API order but appears to do nothing. The page runs in evergreen browsers with no build step, and two products are deliberately priced identically to test the tie-break.

1. Why is the boolean comparator invalid, and what should replace it?

Read the worked answer

sort expects a comparator returning a negative number, zero, or a positive number. (a, b) => a.price > b.price returns a boolean, coerced to 1 or 0 - never negative. The engine is then free to treat 'greater' inconsistently, and because the comparator never reports 'less than', the output depends on the engine's algorithm and input order. That explains '100.00' landing before '25.50' and ties moving between runs. Replace it with rows.sort((a, b) => Number(a.price) - Number(b.price) || a.id.localeCompare(b.id)). Number('100.00') is 100, so numeric order is correct, and the || tie-breaker gives stable output for equal prices. Guard missing values: Number(null) and Number('') are 0, while Number(undefined) and Number('abc') are NaN; a NaN comparator result makes the ordering undefined. Use a default only if a missing price genuinely means free.

2. Explain the two different grand totals (`29.970000000000002` and `'29.97'`) and give a cent-accurate approach.

Read the worked answer

toFixed(2) returns a string, not a number. So (price * qty).toFixed(2) yields '9.99', and summing those strings with + concatenates them, producing something like '9.9925.50…'; if one operand is a number instead, the string is promoted back to a number and binary floating-point noise such as 29.970000000000002 appears. Also, rounding each line then summing can differ by a cent from rounding the sum once. Two correct patterns: sum raw numbers and format once at display time, or - better for money - compute integer cents: Math.round(Number(price) * 100) * qty, sum the integers, then format (cents / 100).toFixed(2). Be aware toFixed has binary quirks ((1.005).toFixed(2) gives '1.00'), so Intl.NumberFormat with a currency style or a cents-based model is safer for finance. Never feed strings into + for arithmetic.

3. Why does the Reset link fail, and what does that reveal about `sort`?

Read the worked answer

Array.prototype.sort sorts in place and returns the same array reference. If the page also renders an 'original order' view from the same array, that array is reordered the first time the sort runs; Reset re-renders the already-sorted array, so nothing appears to change. It also means the second header click sorts already-sorted data, which contributes to the apparent tie shuffling. Fix: sort a copy - const sorted = [...rows].sort(comparator) or rows.slice().sort(...) - and keep rows untouched as the source of truth. The same caution applies to reverse, splice, push, and fill. If the state is shared across modules, freezing the source with Object.freeze(rows) or mapping to view models first makes accidental mutation throw or show up in review. Reset then works because it re-renders the untouched source array.