Engineering guide
Locale-Aware Formatting Implementation
A copyable implementation guide for locale-aware numbers, currencies, dates, time zones, numbering systems, semantic parts, and production-safe tests.
Reviewed 2 August 2026 · Editorial owner: localization.guide
Start with meaning, locale, and time zone
Formatting is a data transformation with three inputs: the semantic value, the user's locale, and—in the case of dates—the intended time zone. Country alone is not a locale, and a locale is not a time zone. Store machine values in stable forms, then format only at the display boundary.
Store
Numbers as numeric values; instants as ISO strings or epoch values; currency as an ISO code.
Resolve
A supported BCP 47 locale plus an explicit time zone and product-specific preferences.
Render
Use Intl formatters at the final UI, email, document, or API presentation boundary.
The same values produce different valid output
These outputs show why punctuation replacement is not localization. Group sizes, digit shapes, spacing, symbol position, field order, and invisible bidirectional controls can all change together.
| Locale | Number | Currency | Date | Implementation note |
|---|---|---|---|---|
| en-US | 1,234,567.89 | $1,234,567.89 | 8/2/26 | Western grouping; month/day/year |
| de-DE | 1.234.567,89 | 1.234.567,89 € | 02.08.26 | Comma decimal; day.month.year |
| fr-FR | 1 234 567,89 | 1 234 567,89 € | 02/08/2026 | Narrow no-break-space grouping |
| hi-IN | 12,34,567.89 | ₹12,34,567.89 | 2/8/26 | Three-digit primary, two-digit secondary groups |
| ar-EG-u-nu-arab | ١٬٢٣٤٬٥٦٧٫٨٩ | ١٬٢٣٤٬٥٦٧٫٨٩ ج.م. | ٢/٨/٢٠٢٦ | Arabic-Indic digits and bidirectional marks |
Illustrative output is runtime-dependent. Confirm the exact result in the JavaScript and ICU versions used in production.
Create reusable formatter factories
Constructing a formatter documents the contract: locale, style, currency, time zone, and precision. Cache frequently reused instances instead of scattering implicit defaults through components.
type FormatContext = {
locale: string;
timeZone: string;
currency: string;
};
export function createFormatters(context: FormatContext) {
return {
decimal: new Intl.NumberFormat(context.locale, {
maximumFractionDigits: 2,
}),
money: new Intl.NumberFormat(context.locale, {
style: "currency",
currency: context.currency,
}),
date: new Intl.DateTimeFormat(context.locale, {
dateStyle: "medium",
timeZone: context.timeZone,
}),
dateTime: new Intl.DateTimeFormat(context.locale, {
dateStyle: "medium",
timeStyle: "short",
timeZone: context.timeZone,
}),
};
}
const f = createFormatters({
locale: "de-DE",
currency: "EUR",
timeZone: "Europe/Berlin",
});
f.decimal.format(1234567.89);
f.money.format(1234567.89);
f.dateTime.format(new Date("2026-08-02T16:30:00Z"));Do not confuse locale with time zone
A user can prefer English while living in Tokyo, or German while viewing an event scheduled in New York. The locale controls how the date is written; the time zone controls which local date and time the instant represents. Leaving the time zone implicit makes server and browser output differ.
const instant = new Date("2026-12-31T23:30:00Z");
const london = new Intl.DateTimeFormat("en-GB", {
dateStyle: "full",
timeStyle: "short",
timeZone: "Europe/London",
}).format(instant);
const tokyo = new Intl.DateTimeFormat("en-GB", {
dateStyle: "full",
timeStyle: "short",
timeZone: "Asia/Tokyo",
}).format(instant);
// Same language preference, different calendar day and local time.Treat numbering systems as a first-class option
BCP 47 Unicode extensions can request a numbering system. For example, ar-EG-u-nu-arab asks for Arabic-Indic digits andar-EG-u-nu-latn asks for Latin digits. Check the resolved options because runtimes can fall back when a request is unsupported.
const requested = "ar-EG-u-nu-arab";
const formatter = new Intl.NumberFormat(requested, {
style: "currency",
currency: "EGP",
});
console.log(formatter.format(1234567.89));
console.log(formatter.resolvedOptions().numberingSystem);
const supported = Intl.NumberFormat.supportedLocalesOf([
"ar-EG-u-nu-arab",
"hi-IN",
"th-TH-u-nu-thai",
]);Use semantic parts for custom layouts
If design requires separate styling for a symbol or fraction, useformatToParts(). Never split on commas, spaces, or a known currency symbol. Preserve every returned literal and bidirectional mark when reassembling output.
const formatter = new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
});
const parts = formatter.formatToParts(1234.5);
const whole = parts.map(({ value }) => value).join("");
const currency = parts.find(({ type }) => type === "currency")?.value;
const groupCharacters = parts
.filter(({ type }) => type === "group")
.map(({ value }) => value);
// Use typed parts for styling; announce the complete value to assistive tech.Production checklist
- Resolve locales against an explicit supported-locale list and documented fallback.
- Keep a currency code beside the amount; never infer currency from language.
- Let the currency define default fraction digits unless the product contract says otherwise.
- Pass an IANA time-zone identifier when calendar-day accuracy matters.
- Use
dir="auto"or an isolated direction boundary around mixed-direction output. - Test French spaces, Indian grouping, RTL output, zero- and three-decimal currencies, negative values, and daylight-saving boundaries.
- Pin or record the runtime and ICU/CLDR data version for screenshot baselines.
Test the contract, not incidental glyphs
Exact snapshots can fail after a standards-data update even when the output remains valid. Assert semantic parts, resolved options, and application invariants in unit tests; reserve exact strings for contracts that genuinely require them.
import assert from "node:assert/strict";
import test from "node:test";
test("JPY uses its currency precision and exposes a currency part", () => {
const formatter = new Intl.NumberFormat("ja-JP", {
style: "currency",
currency: "JPY",
});
const options = formatter.resolvedOptions();
const parts = formatter.formatToParts(1234);
assert.equal(options.currency, "JPY");
assert.equal(options.maximumFractionDigits, 0);
assert.ok(parts.some((part) => part.type === "currency"));
});Primary and official sources
- Intl: internationalization API namespace — MDN Web Docs
- Unicode Locale Data Markup Language: Numbers — Unicode Consortium
- Unicode Locale Data Markup Language: Dates — Unicode Consortium
- Language Tags and Locale Identifiers for the World Wide Web — W3C
- ECMAScript Internationalization API — ECMA TC39