Mesrai
Back to blog
// essayTechnical Deep Dive

Number.toFixed for Money: Why Mesrai Always Says No

Real PR catch: toFixed for currency formatting — runtime inconsistency. Integer-paise + Intl fix.

Mesrai TeamAugust 22, 20268 min read

A money formatter using `Number.toFixed(2)`. Looks like the right tool for the job. Different runtimes implement the rounding subtly differently, and on float inputs the result can flip-flop between values for the same nominal number.

The vulnerable diff

tsbilling/format.ts
// billing/format.ts
function formatMoney(amount: number): string {
  // BUG: float input + toFixed has rounding inconsistencies
  return amount.toFixed(2);
}

// 1.005.toFixed(2)  // → "1.00" (NOT "1.01") in V8 due to float repr.
// 1.015.toFixed(2)  // → "1.02" in some runtimes, "1.01" in others.

What is wrong

Two problems. First, `Number.toFixed` is specified to use round-half-to-even (banker's rounding) in some places and round-half-away-from-zero in others — implementations historically differed. Second, the inputs are floats, so `1.005` isn't actually 1.005 in memory — it's `1.00499999...`, which rounds down. The result: `(1.005).toFixed(2)` returns `"1.00"` in V8, despite the mathematical expectation of `"1.01"`. For accounting, this kind of inconsistency adds up over many transactions. The fix is integer minor-units throughout and explicit rounding before display.

The attack

Examples:

textexploit
(1.005).toFixed(2)     // → "1.00" (V8)
(1.015).toFixed(2)     // → "1.01" (V8; "1.02" elsewhere)
(0.1 + 0.2).toFixed(2) // → "0.30" — looks fine, but the underlying value is 0.30000000000000004

Discovered usually via accountant reconciling reports.

Mesrai's review comment

textreview-comment.txt
mesraipilot · Bot · reviewed 30 sec ago

[mesrai] [code-review] [Logic] [Rounding] [medium]

`Number.toFixed` is unreliable for money:
  - input is float, so 1.005 isn't really 1.005
  - rounding mode differs across runtimes

Use integer minor-units. Store paise. Convert + format at display:

  function formatRupees(paise: number): string {
    const sign = paise < 0 ? "-" : "";
    const abs  = Math.abs(paise);
    const rupees = Math.trunc(abs / 100);
    const fraction = String(abs % 100).padStart(2, "0");
    return `${sign}${rupees}.${fraction}`;
  }

For non-integer cases (mid-calculation), use a decimal library
(big.js, decimal.js) with the rounding mode explicit:

  new Big(value).round(2, Big.roundHalfUp).toString();

The fix

tsbilling/format.ts (fixed)
// billing/format.ts — fixed (integer paise)
export function formatRupees(paise: number): string {
  const sign = paise < 0 ? "-" : "";
  const abs  = Math.abs(paise);
  const rupees = Math.trunc(abs / 100);
  const fraction = String(abs % 100).padStart(2, "0");
  return `${sign}${rupees}.${fraction}`;
}

// For Intl-aware locale formatting (Indian comma style etc.):
export function formatRupeesIntl(paise: number, locale = "en-IN"): string {
  return new Intl.NumberFormat(locale, {
    style: "currency", currency: "INR", minimumFractionDigits: 2,
  }).format(paise / 100);
}

Manual integer formatting for the canonical path. `Intl.NumberFormat` for locale-aware display (handles Indian comma grouping correctly).

Why human review missed it

Money rounding lives at the intersection of three error-prone topics: floats, rounding modes, and locale formatting. Mesrai catches every `Number.toFixed` and `Math.round` on a currency variable and recommends explicit integer-paise math + Intl formatting.

Related rules + further reading

Mesrai rule pack: logic/no-tofixed-on-money — flags toFixed on currency variables.

Mozilla MDN: Number.toFixed — rounding caveats.

ECMA-262 § 21.1.3.3 — toFixed specification.

Takeaway

toFixed lies on money. Integer paise + manual format. Or decimal lib with explicit rounding. Mesrai catches the gap.

// try

See it on your next PR.

Free for individuals. Install in two minutes. Mesrai reviews every commit.