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.

LocaleDecimalCurrencyWhat changes
en-US1,234,567.89$1,234,567.89comma grouping; period decimal
de-DE1.234.567,891.234.567,89 €period grouping; comma decimal
fr-FR1 234 567,891 234 567,89 €narrow no-break-space grouping
hi-IN12,34,567.89₹12,34,567.89lakh/crore grouping after the first three digits
ar-EG١٬٢٣٤٬٥٦٧٫٨٩‏١٬٢٣٤٬٥٦٧٫٨٩ ج.م.‏Arabic locale symbols and bidirectional output
ja-JP1,234,567.89¥1,234,568yen 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 3

Test 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