skip to content

A checkout page renders prices with '$' + amount.toFixed(2). What breaks once the product ships in more locales and currencies, and what does Intl.NumberFormat's style: 'currency' handle instead?

level: middleimportance: should knowfreq 50%

answer

  1. symbol, position, separators, digit count
  2. the currency code drives fraction digits
  3. zero-decimal currencies exist
  4. currency option is mandatory here
  5. format at render, store the raw amount

basics

~20 s

Hand-rolled currency hardcodes the symbol, its position, the separators and two decimal places. Intl.NumberFormat with style: 'currency' and a currency code derives all of them from locale and currency data — JPY gets zero decimals, German puts the symbol last.

solid answer

~40 s

`'$' + amount.toFixed(2)` bakes in four assumptions that are all locale- or currency-specific: the symbol, the symbol's position, the grouping and decimal separators, and the number of fraction digits. `new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(1234.5)` gives `"1.234,50 €"` — symbol trailing, dot grouping, comma decimal — while the same call with `'ja-JP'` and `'JPY'` rounds to whole yen, because the fraction-digit count comes from the currency, not from you. Note that `style: 'currency'` *requires* a `currency` option; omit it and the constructor throws a TypeError, and an ill-formed code throws a RangeError. Related options are `currencyDisplay` (`'symbol'`, `'narrowSymbol'`, `'code'`, `'name'`) and `currencySign: 'accounting'`, which renders negatives in parentheses in locales that expect that.

code

javascript · 16 lines
javascript
const amount = 1234.5;

console.log(new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount));
// "$1,234.50"

console.log(new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(amount));
// "1.234,50 EUR-symbol" -> dot grouping, comma decimal, symbol last

console.log(new Intl.NumberFormat('ja-JP', { style: 'currency', currency: 'JPY' }).format(amount));
// rounded to whole yen: JPY has zero fraction digits

try {
  new Intl.NumberFormat('en-US', { style: 'currency' });
} catch (e) {
  console.log(e.constructor.name); // "TypeError" - currency is mandatory
}

go deeper

for a junior

Be able to write the call: a locale tag, style: 'currency', and a three-letter currency code. Know that omitting the currency code is an error, not a default, and that the separators change with the locale.

for a middle

Explain the split of responsibility — the locale governs layout and separators, the currency code governs fraction digits — and name a zero-decimal currency. Mention currencyDisplay and currencySign: 'accounting' as the tuning knobs.

for a senior

Show that you treat formatted text as presentation: raw amounts in the data layer, one cached formatter per locale-and-currency pair, formatting at the render boundary. Be ready to explain why a formatted string in an API response is a design defect.

for a principal

Own the policy: where currency lives in the domain model, how minor-unit integers or a decimal type keep arithmetic exact, and how formatting is confined to one edge module so locale rollout is a data change rather than a code sweep.

## What the hand-rolled version assumes `'$' + amount.toFixed(2)` encodes four independent decisions: 1. **The symbol** — `$` is wrong for every non-dollar currency, and ambiguous between the dollar currencies. 2. **Its position** — leading in English, trailing in German, French and most of Europe. 3. **The separators** — `toFixed` always emits a `.` decimal and no grouping at all; German wants `.` for grouping and `,` for the decimal. 4. **The fraction digits** — two is the majority, not the rule. All four are data the runtime already ships. ## The constructor ```js const v = 1234.5; new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(v); // "$1,234.50" new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(v); // "1.234,50 €" (the space before the symbol is non-breaking) new Intl.NumberFormat('ja-JP', { style: 'currency', currency: 'JPY' }).format(v); // whole yen — JPY has zero fraction digits, so the value is rounded ``` The locale decides layout and separators; the **currency code** decides the digit count. JPY takes 0, the majority of currencies take 2, and a few — Kuwaiti dinar among them — take 3. Hardcoding `.toFixed(2)` produces a wrong-looking price in the first case and a truncated one in the last. ## Argument rules worth knowing - `style: 'currency'` with no `currency` option throws a **TypeError**. There is no default currency, deliberately: guessing one from the locale would be a data-loss bug. - A malformed code (not three letters) throws a **RangeError**. - `currencyDisplay` chooses between `'symbol'` (default), `'narrowSymbol'` (`$100` rather than `US$100` in locales that disambiguate), `'code'` (`USD 100.00`) and `'name'` (`100.00 US dollars`). - `currencySign: 'accounting'` renders negatives the way finance expects — `($1,234.50)` in en-US instead of `-$1,234.50`. - You can still override digits with `minimumFractionDigits` / `maximumFractionDigits` when you deliberately want, say, whole-dollar display. ## Beyond currency The same constructor covers the rest of the numeric surface: ```js new Intl.NumberFormat('en', { style: 'percent' }).format(0.256); // "26%" new Intl.NumberFormat('en', { notation: 'compact' }).format(1234567); // "1.2M" new Intl.NumberFormat('en', { style: 'unit', unit: 'kilometer-per-hour' }) .format(50); // "50 km/h" ``` `style: 'percent'` multiplies by 100 for you — passing `25` gives `2,500%`, a classic slip. `style: 'unit'` requires a `unit` from the sanctioned identifier list. `notation: 'compact'` produces the "1.2M" shape with a `compactDisplay` of `'short'` or `'long'`. ## Numbering systems and the wider point Some locales default to a non-Latin numbering system, so the digits themselves change — something string concatenation can never produce. This is the general argument: formatting is *data*, shipped with the runtime and updated with it, and reimplementing it in application code means reimplementing it badly and then maintaining it. ## What Intl.NumberFormat does not fix It formats; it does not do arithmetic. If your totals are computed by adding binary floating-point numbers, the formatter faithfully renders whatever wrong value arrives. Money arithmetic belongs in minor units (integer cents) or a decimal library; `Intl.NumberFormat` is the last step, after the number is already correct. Note that `format()` accepts a BigInt as well as a Number, which pairs well with integer-minor-unit arithmetic — though you must scale the value yourself before formatting. ## Cost and reuse Constructing a formatter loads and resolves locale data; formatting with it is cheap. In a table of a thousand rows, build one formatter outside the loop. A common production pattern is a small memoized factory keyed by locale plus a stable serialization of the options. ```js const cache = new Map(); function money(locale, currency) { const key = locale + '|' + currency; let f = cache.get(key); if (!f) { f = new Intl.NumberFormat(locale, { style: 'currency', currency }); cache.set(key, f); } return f; } ``` Finally, treat the output as presentation only. Storing `"$1,234.50"` in a database or returning it from an API freezes one locale's rendering into your data and makes the amount unusable for arithmetic or re-formatting.

  • Why does style: 'currency' refuse to default the currency from the locale?
    Because locale and currency are independent facts. A `de-DE` user may be viewing a price in USD, and a `en-US` page may show EUR. Inferring the currency from the language tag would silently relabel money — the worst kind of formatting bug — so the specification makes the omission a TypeError instead of a guess.
  • How would you render a value in whole dollars while keeping correct locale layout?
    Keep `style: 'currency'` and override the digit count: `new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 })`. You get locale-correct symbol placement, grouping and separators, with the currency's default of two digits deliberately overridden. Never fall back to string surgery on the formatted output to strip the cents.
  • Does Intl.NumberFormat make money arithmetic safe?
    No — it is strictly a rendering step. It formats whatever number it is handed, including one already spoiled by binary floating-point addition. Do the arithmetic in integer minor units or a decimal type, then format the correct value. `format()` accepting BigInt helps here, but you still scale from minor units yourself before formatting.

saying these in an interview costs you the question

  • Assuming every currency shows two decimal places
  • Hardcoding the symbol before the number for all locales
  • Thinking Intl.NumberFormat solves floating-point money errors
  • Storing formatted currency strings as the canonical amount
  • Passing 25 to style: 'percent' expecting 25%

context