Skip to content

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].

Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories