Build1 publisher3 min readPublished
The money bug that survives your migration to decimals
A dev.to post argues the expensive defect is not float drift but a money type that carries an amount and nothing else. Swapping doubles for Decimal leaves the hole open.
The Engineer · Build desk
Drafted by a language model from the sources cited here and checked against its claim ledger before publication. How we use AISend a correction
What happened
- Most guides open with 0.1 + 0.2 === 0.30000000000000004 and conclude "don't use floats for money"; the post calls this true and not very useful.
- The bug most likely to reach production, per the post, is a function that adds 500 US dollars to 500 Japanese yen and returns 1000 of nothing.
- Money is a number, a currency, a scale and a rounding policy; if the type carries only the first, the other three end up scattered across call sites as assumptions.
- A loop adding 0.1 ten times to a balance starting at 0 yields 0.9999999999999999, not 1, and the result looks obviously wrong as soon as it is printed.
- Math.round(1.005 * 100) / 100 returns 1, not 1.01.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
A post on dev.to takes the standard money advice, demonstrated with `0.1 + 0.2 === 0.30000000000000004` and concluded with "don't use floats," and calls it true but not very useful [1]. The defect it says is most likely to reach production is different in kind: a function that adds 500 US dollars to 500 Japanese yen and returns 1000 of nothing [2].
The framing is that money is four things at once, a number, a currency, a scale and a rounding policy, and that a type carrying only the first scatters the other three across call sites as assumptions [3].
There are two float bugs, and only one of them is loud. Adding 0.1 ten times and landing on 0.9999999999999999 announces itself the moment you print it [4]. The quiet one is `Math.round(1.005 * 100) / 100`, which returns 1 rather than 1.01 [5], because the nearest double to 1.005 is 1.00499999999999989342, so multiplying by 100 gives 100.49999999999999 and rounding that down is arithmetically correct [6]. The author's point is operational: round-to-nearest-cent is the single operation nobody thinks to unit test, and it is wrong for a value a human typed into a form [7]. There is also a ceiling, since doubles are exact on integers only up to `Number.MAX_SAFE_INTEGER`, 9007199254740991 [8], roughly 90 trillion dollars in cents, which holds until an amount arrives in a currency with a smaller minor unit or you accumulate a notional across a whole book [9]. At an exponent of 3 that same headroom is about 9 trillion units [10].
decimal.js, Java's `BigDecimal` and Postgres `NUMERIC` all hold 1.005 exactly, and every arithmetic complaint above goes away [11]. The currency complaint does not: `new Decimal("500").plus(new Decimal("500"))` is 1000 of nothing in particular, because a Decimal carries no currency, no scale policy and no rounding mode [12]. In the author's experience, essentially every expensive money bug lives in that hole rather than in the third decimal place [13].
The recommended representation is integer minor units in a `BigInt`: exact, with no rounding at rest [14]. The trap arrives one line later, when someone divides by 100 for display, which the post says is wrong for a third of the world because ISO 4217 assigns every currency an exponent and it is not always 2 [15]. JPY, KRW and VND are zero-decimal; KWD, BHD, OMR, JOD and TND are three-decimal [16]. So the exponent is a lookup, and the decimal string it produces can be handed to `Intl.NumberFormat`, whose `format()` accepts a string rather than only a number, so an exact value never round-trips through a double [17]. Intl already knows each currency's exponent [18].
On rounding, half away from zero sends every tie the same direction, so error accumulates instead of cancelling: over 0.5 to 4.5 the exact sum is 12.5, half-up gives 15 and half-even gives 12 [19]. That is an overshoot of 2.5 against an undershoot of 0.5 on five ties [20]. Half-even is the IEEE-754 default [21] and still not automatically correct, because tax authorities, card scheme rules and loan regulations sometimes specify a mode, and not always the unbiased one [22]. Which is why the mode belongs in the signature, `convert(from, rate, mode)`, rather than defaulted three layers down in a formatting helper [23].
Then there is splitting. Ten dollars three ways gives 3.33 each, three of which is 9.99; a cent has gone missing, and if the other side of the entry is a real bank transfer the ledger no longer balances [24]. The operation you want is allocation, not division [25].
Two things worth checking in your own code, both cheap. Any `/ 100` in a display path is an assumption about exponent that will fail on a yen or dinar amount [15][16]. Any rounding call reachable from a formatter is a policy decision that no signature records [23].