Engineering guide
Locale-Aware Number and Currency Formatting
Implement numbers and currencies with Intl.NumberFormat, formatToParts(), Unicode grouping rules, non-ASCII separators, and durable semantic tests.
Reviewed 19 July 2026 · Editorial owner: localization.guide
One value, six valid representations
Formatting is not a search-and-replace operation. A locale controls digit shapes, separator characters, grouping sizes, sign placement, currency placement, spacing, and default precision. The currency controls its own minor-unit defaults, so Japanese yen and Kuwaiti dinar do not behave like US dollars.
| Locale | Decimal | Currency | What changes |
|---|---|---|---|
| en-US | 1,234,567.89 | $1,234,567.89 | comma grouping; period decimal |
| de-DE | 1.234.567,89 | 1.234.567,89 € | period grouping; comma decimal |
| fr-FR | 1 234 567,89 | 1 234 567,89 € | narrow no-break-space grouping |
| hi-IN | 12,34,567.89 | ₹12,34,567.89 | lakh/crore grouping after the first three digits |
| ar-EG | ١٬٢٣٤٬٥٦٧٫٨٩ | ١٬٢٣٤٬٥٦٧٫٨٩ ج.م. | Arabic locale symbols and bidirectional output |
| ja-JP | 1,234,567.89 | ¥1,234,568 | yen defaults to zero fractional digits |
Use platform APIs, not hardcoded punctuation
`Intl.NumberFormat` selects locale data supplied by the runtime. Pass the locale and the semantic style you need. Do not format in one locale and then swap commas and periods: that approach misses grouping intervals, non-breaking spaces, signs, digits, and currency rules.
const value = 1234567.89;
const decimal = new Intl.NumberFormat("de-DE", {
style: "decimal",
maximumFractionDigits: 2,
}).format(value);
const currency = new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
currencyDisplay: "symbol",
}).format(value);
const percent = new Intl.NumberFormat("ar-EG", {
style: "percent",
maximumFractionDigits: 1,
}).format(0.126);
const compact = new Intl.NumberFormat("hi-IN", {
notation: "compact",
maximumSignificantDigits: 3,
}).format(12345678);Separators may be invisible or non-ASCII
Spaces are data
French output commonly uses a narrow no-break space (`U+202F`) between integer groups and a no-break space (`U+00A0`) before the currency. Those characters prevent awkward line breaks and are not equivalent to an ASCII space.
Grouping is not always thousands
Indian formatting uses a primary group of three digits and secondary groups of two: `1,23,45,678`. Unicode CLDR models primary and secondary grouping sizes separately, so a single “thousands separator” field cannot represent every locale.
Numbering systems also change the digits themselves. For example, the Unicode locale extension in `ar-EG-u-nu-arab` requests Arabic-Indic digits, while `ar-EG-u-nu-latn` requests Western digits. Inspect `formatter.resolvedOptions().numberingSystem` when that distinction matters.
Extract semantic parts programmatically
Use `formatToParts()` when the UI must style or place a currency token separately. It returns typed parts such as `integer`, `group`, `decimal`, `fraction`, `currency`, `minusSign`, and `literal`. Rejoin their values to preserve the formatter’s exact output.
const formatter = new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
});
const parts = formatter.formatToParts(1234567.89);
const currency = parts.find((part) => part.type === "currency")?.value;
const groups = parts.filter((part) => part.type === "group").map((part) => part.value);
const rendered = parts.map((part) => part.value).join("");
// Preserve every literal and bidirectional mark from the formatter.
// Do not reconstruct the value from a guessed template.Currency precision belongs to the currency
Never assume two decimal places. Let the runtime apply the currency’s default minor-unit rules, then override precision only for a documented product requirement. JPY normally displays zero fractional digits; KWD normally displays three. Payment APIs may store minor units as integers, which is a separate storage concern from display formatting.
const jpy = new Intl.NumberFormat("ja-JP", {
style: "currency", currency: "JPY",
}).resolvedOptions();
const kwd = new Intl.NumberFormat("ar-KW", {
style: "currency", currency: "KWD",
}).resolvedOptions();
console.log(jpy.maximumFractionDigits); // normally 0
console.log(kwd.maximumFractionDigits); // normally 3Test semantics, not fragile full strings
The specification permits some output variation across implementations, including spacing and bidirectional controls. Pin the runtime/ICU version for screenshot tests. In unit tests, assert resolved options and typed parts whenever an exact visible string is not the contract.
import test from "node:test";
import assert from "node:assert/strict";
test("French currency output exposes semantic parts", () => {
const formatter = new Intl.NumberFormat("fr-FR", {
style: "currency", currency: "EUR",
});
const parts = formatter.formatToParts(1234.5);
assert.equal(parts.find((part) => part.type === "currency")?.value, "€");
assert.equal(parts.find((part) => part.type === "decimal")?.value, ",");
assert.equal(formatter.resolvedOptions().currency, "EUR");
});Primary and official sources
- Intl.NumberFormat reference — MDN Web Docs
- Intl.NumberFormat.formatToParts() — MDN Web Docs
- LDML Part 3: Numbers — Unicode Consortium
- ECMAScript Internationalization API Specification — ECMA TC39