skip to content

How would you represent and compute monetary amounts in a JavaScript service so that totals do not drift, and where should the conversions between representations happen?

level: seniorimportance: should knowfreq 50%

answer

  1. never a fractional double
  2. integers in the smallest unit
  3. parse digits, don't multiply
  4. division is the only rounding point
  5. allocate the leftover deterministically

basics

~20 s

Never hold money in a fractional double. Keep amounts as integers in the smallest unit — cents — or as decimal strings, do all arithmetic on the integers, apply one explicit rounding rule where division forces one, and convert to decimal only at the system's edges.

solid answer

~50 s

The rule is that money is not a fractional number in memory; it is an integer count of minor units plus a currency. Parse to that integer once at every boundary — request payload, database row, third-party response — and never let a decimal double exist in the middle of the pipeline, because `4.35 * 100` is 434.99999999999994 and truncating it loses a cent. Addition, subtraction and multiplication by an integer quantity stay exact in integers. Rounding is required only where you divide — tax, discounts, splitting a bill — and that should be one named function with a stated rule and a deterministic way of allocating the leftover minor units, such as giving the remainder to the first shares. Where the scale varies or you must round-trip an exact decimal column, carry a decimal string instead and parse it at the edge. Also keep the currency alongside the amount: the number of minor units per major unit is not always two.

code

javascript · 22 lines
javascript
// Parse decimal text to integer minor units without ever creating a double
function parseMinorUnits(text, exponent = 2) {
  const m = /^(-)?(\d+)(?:\.(\d+))?$/.exec(text.trim());
  if (!m) throw new Error(`not a decimal amount: ${text}`);
  const [, sign, whole, frac = ""] = m;
  if (frac.length > exponent) throw new Error(`too many decimals: ${text}`);
  const padded = frac.padEnd(exponent, "0");
  const value = Number(whole + padded);
  return sign ? -value : value;
}

function formatMinorUnits(units, exponent = 2) {
  const sign = units < 0 ? "-" : "";
  const s = String(Math.abs(units)).padStart(exponent + 1, "0");
  return exponent === 0
    ? sign + s
    : `${sign}${s.slice(0, -exponent)}.${s.slice(-exponent)}`;
}

console.log(parseMinorUnits("4.35"));   // 435, not 434.99999999999994
console.log(Math.round(1.005 * 100));   // 100 - the trap this avoids
console.log(formatMinorUnits(435 * 3)); // "13.05"

go deeper

for a junior

Know that money should not live in a fractional number: keep whole cents and divide only when displaying. Recognise that toFixed formats a value rather than making it accurate.

for a middle

Explain why the conversion step is where precision is lost, that integer arithmetic on minor units is exact, and that division is the only operation forcing a rounding decision you have to make explicitly.

for a senior

Design the boundary: a Money type that carries its currency, parsing that never creates a fractional double, a single named rounding function with a stated rule, deterministic remainder allocation, and tests asserting that splits sum back to the whole.

for a principal

Own the cross-service contract: which representation crosses the wire, which rounding rule and rounding point every service applies, how currency exponents are sourced, and how reconciliation proves the ledger — so no two components can disagree about the last minor unit.

## Why the representation is the whole answer Every money bug in a JavaScript service comes from the same place: an amount was held as a fractional double at some point, and a binary rounding error either accumulated across a sum or crossed a cent boundary at the wrong moment. No amount of careful formatting downstream repairs that, because the value was already wrong before formatting ran. So the fix is not a better rounding call; it is a representation in which the error cannot occur. ## Integer minor units The default choice is to store the amount as a whole number of the currency's smallest unit — cents for USD or EUR — together with the currency code. `$19.99` becomes `{ amount: 1999, currency: "USD" }`. Whole numbers are represented exactly by doubles across the range any realistic ledger reaches, so: - addition and subtraction of amounts are exact; - multiplying by an integer quantity (3 items at 1999) is exact; - comparison is `===`, with no tolerance and no arguing about whether one cent counts as equal; - serialising and reconciling with another system is unambiguous. The conversion into that form is the dangerous step, and it should not be done by multiplying a double: ```js Math.round(4.35 * 100); // 435 - works here... Math.round(1.005 * 100); // 100 - ...and loses a cent here ``` When the input arrives as text — a JSON string, a form field, a CSV cell — parse the digits rather than the number: split on the decimal point, pad or check the fractional part, and build the integer from the digit characters. Then the double never appears. ## Decimal strings The alternative representation is a decimal string such as `"19.99"`, and it is the right choice when: - the scale varies across rows or currencies and a single fixed exponent is awkward; - values must round-trip an exact decimal database column byte for byte; - amounts pass through a partner API that specifies decimal strings. JSON numbers are parsed into doubles by `JSON.parse`, so an amount transported as a bare JSON number has already been through a binary rounding by the time your code sees it. A string survives transport exactly. The trade is that strings cannot be computed on: you parse to integer minor units to do arithmetic, then render back. ## Rounding is a decision, not an accident Addition never needs rounding. Division always does: tax at 8.25 percent, a 15 percent discount, splitting a bill three ways. Make that one explicit function, with three things pinned down: 1. **The rule.** Half-up, half-even, always-down — jurisdictions and contracts differ, and `toFixed`'s away-from-zero behaviour is not automatically the one you owe. 2. **The point of application.** Round once per line item, or once on the invoice total, but decide which — the two produce different totals and both can be defensible. 3. **The remainder.** Splitting 1000 cents three ways leaves one cent over. Allocate it deterministically (largest-remainder, or simply to the earliest shares) and assert that the parts sum back to the whole. ```js function split(totalCents, n) { const base = Math.floor(totalCents / n); const rem = totalCents - base * n; return Array.from({ length: n }, (_, i) => base + (i < rem ? 1 : 0)); } split(1000, 3); // [334, 333, 333] - sums back to 1000 ``` The invariant worth testing is that no allocation ever creates or destroys a minor unit. ## Where the conversions live Draw the boundary explicitly. Inbound: parse to integer minor units in the adapter that owns the transport, and reject anything with more fractional digits than the currency allows rather than silently rounding it. Outbound: format from integer minor units at the moment of rendering or serialising. In between, no function should accept or return a fractional amount. A dedicated `Money` value type with private construction makes that boundary enforceable rather than aspirational, and gives you one place to keep the currency attached. ## Details that catch teams out - **Minor-unit scale is per currency.** Two decimals is not universal: JPY has none, and several currencies use three. Hard-coding `/ 100` breaks the moment a second currency appears. - **Never add amounts in different currencies.** Make the type refuse it rather than trusting discipline. - **Percentages and unit prices are not money.** A tax rate or a per-litre price may legitimately need more precision; keep it as a separate, clearly named quantity so it is never confused with an amount. - **Very large totals.** Integers held in doubles are exact only up to a limit; if your ledger can genuinely exceed that range in minor units, the representation must change accordingly. Most services never approach it, but the boundary should be a conscious decision rather than an assumption. - **Don't reach for a tolerance.** If a reconciliation test needs an epsilon, some part of the pipeline is still using doubles. Find it.

  • Why parse an input like "4.35" digit by digit instead of Number("4.35") * 100?
    Because `Number("4.35")` produces a double and `4.35 * 100` is 434.99999999999994; truncating loses a cent, and `Math.round` merely hides the class of bug until a value like 1.005 lands on the wrong side. Splitting the string on the decimal point and building the integer from the digit characters never creates a fractional double at all.
  • You split 1000 cents three ways. What must the code guarantee?
    That the parts sum back to exactly 1000. Base shares of 333 leave one cent over, so allocate the remainder deterministically — to the earliest shares, or by largest remainder — and assert the invariant in a test. Independent rounding of 333.33 per share silently destroys a cent, which shows up later as an unreconcilable ledger.
  • Is dividing by 100 a safe way to hold amounts for every currency?
    No. The number of minor units per major unit is a property of the currency, not a constant: JPY has no minor unit and several currencies use three digits. Carry the currency code with the amount and take the exponent from it, otherwise the first non-two-decimal currency multiplies or divides every amount by 100.

saying these in an interview costs you the question

  • Stores prices as doubles and rounds with toFixed at the end
  • Converts by multiplying the parsed double by 100
  • Compares monetary amounts with an epsilon tolerance
  • Assumes every currency has exactly two decimal places
  • Rounds each share independently when splitting a total

context