JavaScript Intl API — Dates, Numbers, Currency & Locale-Aware Formatting Without Hand-Rolling
Why not to hand-roll i18n formatting: Intl.NumberFormat, DateTimeFormat, PluralRules, Collator, Segmenter, performance, and timezone pitfalls.
Bạn ship trang pricing và hard-code $1,234.56. User Đức thấy ký hiệu dollar nơi họ mong 1.234,56 €. Bạn format ngày 12/27/2025 và user Việt đọc thành ngày 12 tháng 27. Bạn pluralize bằng count === 1 ? 'item' : 'items' và tiếng Ba Lan hỏng. Đây không phải edge case — chúng là mặc định khi bạn tự format locale.
Trình duyệt ship sẵn Internationalization API dưới namespace Intl. Nó mã hóa dữ liệu locale CLDR — dấu thập phân, vị trí currency, hệ lịch, quy tắc số nhiều, thứ tự collation — để bạn không phải tự làm. Bài này dành cho senior frontend engineer cần formatting production-grade mà không kéo thư viện 200 KB cho mỗi con số.
Mở demo đầy đủ:
Tại sao không bao giờ tự format
Quy tắc locale không phải preference thẩm mỹ — chúng có ý nghĩa ngữ pháp và pháp lý. Sai chúng làm mất niềm tin trên checkout, dashboard, và admin tool.
| Concern | Hand-rolled trap | What Intl handles |
|---|---|---|
| Decimal separator | Always . | . in en-US, , in de-DE, ٫ in ar-EG |
| Grouping | Always , every 3 digits | 1,234,567 vs 1.234.567 vs 12,34,567 (Indian lakhs) |
| Currency symbol | Prefix $ | $1.00 (en-US) vs 1,00 $ (fr-FR) vs USD 1.00 (some locales) |
| Percent | Append % | -12% vs -12 % vs -12٪ depending on locale |
| Date order | MM/DD/YYYY | 27/12/2025 (vi-VN), 2025/12/27 (ja-JP), non-Gregorian calendars |
| RTL | Ignore direction | Arabic and Hebrew need symbol placement and bidirectional text |
| Pluralization | === 1 check | one, few, many, other categories per locale |
| Sort order | Unicode code points | Accent-aware, case-aware, locale-specific (e.g. ä near a in German) |
Bẫy
toLocaleString(): Gọi(1234.5).toLocaleString()không có options dùng locale mặc định runtime (thường ngôn ngữ browser/OS), không phải locale app bạn chọn. Tệ hơn:(1234.5).toLocaleString('de-DE')cho chuỗi decimal nhưng không có currency symbol, unit, compact notation trừ khi bạn truyền object options đầy đủ — và hành vi khác nhau giữa engine khi options thiếu. Ưu tiên constructorIntl.*rõ ràng với instance được cache.
// ❌ Implicit locale, no control
price.toLocaleString();
// ❌ Locale passed but still no currency semantics
price.toLocaleString('de-DE');
// ✅ Explicit, reusable, testable
const eurFormatter = new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
});
eurFormatter.format(price);
Intl.NumberFormat — số, currency, percent, unit, compact
Intl.NumberFormat là công cụ chính cho mọi hiển thị số: giá, phần trăm, dung lượng file, khoảng cách, KPI delta.
Core styles
const locale = 'de-DE';
const value = 1234567.89;
// Decimal — grouping + separator follow locale
new Intl.NumberFormat(locale).format(value);
// → "1.234.567,89"
// Currency — symbol placement + fraction digits from ISO 4217
new Intl.NumberFormat(locale, {
style: 'currency',
currency: 'EUR',
}).format(value);
// → "1.234.567,89 €"
// Percent — value is multiplied by 100 internally
new Intl.NumberFormat(locale, { style: 'percent' }).format(0.875);
// → "87,5 %"
// Unit — length, mass, temperature, digital storage
new Intl.NumberFormat('en-US', {
style: 'unit',
unit: 'kilometer',
unitDisplay: 'long',
}).format(42);
// → "42 kilometers"
Compact notation for dashboards
Compact notation (notation: 'compact') tạo 1.2M, 1,2 Mio., 120万 — cần thiết cho UI dày nơi grouping đầy đủ tràn.
new Intl.NumberFormat('en-US', {
notation: 'compact',
compactDisplay: 'short',
maximumFractionDigits: 1,
}).format(1_234_567);
// → "1.2M"
formatToParts — build custom UI without string parsing
Khi cần style currency symbol riêng (cent superscript, dấu màu), không bao giờ regex-parse chuỗi đã format. Dùng formatToParts():
const parts = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).formatToParts(-1234.56);
// [
// { type: 'minusSign', value: '-' },
// { type: 'currency', value: '$' },
// { type: 'integer', value: '1,234' },
// { type: 'decimal', value: '.' },
// { type: 'fraction', value: '56' },
// ]
Map type sang DOM node. Cách này sống sót khi đổi locale mà không cần split chuỗi mong manh.
formatRange — price spans and date-adjacent numbers
const fmt = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
fmt.formatRange(19.99, 29.99);
// → "$19.99 – $29.99" (en-dash inserted by locale rules)
| Option | Use case |
|---|---|
minimumFractionDigits / maximumFractionDigits | Crypto (8 decimals) vs JPY (0 decimals) |
signDisplay: 'exceptZero' | Show + on positive deltas in trading UI |
roundingMode: 'floor' | Always round down prices (legal requirement in some jurisdictions) |
currencyDisplay: 'code' | Show USD instead of $ in multi-currency admin panels |
Intl.DateTimeFormat — ngày, giờ, time zone, lịch
Date là thảm họa tự format phổ biến thứ hai sau số. Intl.DateTimeFormat xử lý thứ tự ngày, tên tháng, 12h vs 24h, và chuyển time zone từ một Date hoặc timestamp.
const instant = new Date('2025-12-27T15:30:00.000Z');
// Preset styles — let the locale decide field order and separators
new Intl.DateTimeFormat('en-US', {
dateStyle: 'full',
timeStyle: 'short',
timeZone: 'America/New_York',
}).format(instant);
// → "Saturday, December 27, 2025 at 10:30 AM"
new Intl.DateTimeFormat('ja-JP', {
dateStyle: 'long',
timeStyle: 'medium',
timeZone: 'Asia/Tokyo',
}).format(instant);
// → "2025年12月28日 0:30:00" (next calendar day in JST)
Granular field control
Khi preset style quá thô, chỉ định từng field:
new Intl.DateTimeFormat('vi-VN', {
weekday: 'short',
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZone: 'Asia/Ho_Chi_Minh',
}).format(instant);
formatToParts for custom date pickers
Cùng pattern với số — tách thành part \{ type, value \} cho calendar component headless.
const parts = new Intl.DateTimeFormat('en-US', {
dateStyle: 'medium',
}).formatToParts(instant);
const byType = Object.fromEntries(parts.map((p) => [p.type, p.value]));
// byType.day, byType.month, byType.year — safe to bind to inputs
Time zone pitfalls
| Pitfall | Reality |
|---|---|
Date is always UTC internally | Display layer must set timeZone explicitly — default is runtime local zone |
| DST gaps and overlaps | 2025-03-09T02:30:00 in America/New_York may not exist; Intl resolves per spec |
| ”Store as UTC, display local” | Correct pattern: persist ISO UTC, format with user’s timeZone option |
| Server renders “today” | Server time zone ≠ user time zone — hydrate or pass user’s IANA zone from profile |
Không bao giờ nối chuỗi ISO date cho UI:
date.getMonth() + 1 + '/' + date.getDate()bỏ qua thứ tự locale và fail với locale RTL. Luôn đi quaIntl.DateTimeFormathoặcformatToParts.
Intl.RelativeTimeFormat — “2 days ago” đúng cách
Timestamp tương đối xuất hiện trong notification, activity feed, và comment thread. Đừng template "${n} days ago" — ngữ pháp khác nhau (thứ tự từ, số nhiều, shortcut “yesterday”).
const rtf = new Intl.RelativeTimeFormat('en-US', { numeric: 'auto' });
rtf.format(-1, 'day'); // → "yesterday"
rtf.format(-2, 'day'); // → "2 days ago"
rtf.format(3, 'hour'); // → "in 3 hours"
numeric: 'auto' cho locale gom -1 day thành “yesterday” khi phù hợp. Bọc trong helper tính delta unit từ hai timestamp:
const DIVISORS = [
[60, 'second'],
[60, 'minute'],
[24, 'hour'],
[7, 'day'],
[4.345, 'week'],
[12, 'month'],
[Infinity, 'year'],
];
function relativeTime(from, to, locale) {
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
let delta = (to - from) / 1000;
for (const [amount, unit] of DIVISORS) {
if (Math.abs(delta) < amount) {
return rtf.format(Math.round(delta), unit);
}
delta /= amount;
}
}
Intl.ListFormat — liên từ và Oxford comma
Tự build "A, B, and C" hỏng với locale dùng liên từ, separator, hoặc không có Oxford comma khác.
const items = ['Chrome', 'Firefox', 'Safari'];
new Intl.ListFormat('en-US', { type: 'conjunction' }).format(items);
// → "Chrome, Firefox, and Safari"
new Intl.ListFormat('de-DE', { type: 'conjunction' }).format(items);
// → "Chrome, Firefox und Safari"
new Intl.ListFormat('en-US', { type: 'disjunction' }).format(items);
// → "Chrome, Firefox, or Safari"
Dùng type: 'unit' cho kích thước như 10 ft × 8 ft × 6 ft. Kết hợp Intl.DisplayNames khi item là enum code (mã quốc gia, mã ngôn ngữ) thay vì chuỗi đã dịch.
Intl.PluralRules — vượt count === 1
Tiếng Anh có hai dạng số nhiều (one, other) nhưng nhiều locale có ba đến sáu. Tiếng Ba Lan phân biệt one, few, many, other. Tiếng Ả Rập có zero, one, two, few, many, other.
const pr = new Intl.PluralRules('pl-PL');
pr.select(1); // → "one"
pr.select(2); // → "few"
pr.select(5); // → "many"
pr.select(22); // → "few"
Nối với message catalog:
const messages = {
en: { one: '{n} item', other: '{n} items' },
pl: { one: '{n} rzecz', few: '{n} rzeczy', many: '{n} rzeczy', other: '{n} rzeczy' },
};
function pluralize(locale, count, messagesForLocale) {
const category = new Intl.PluralRules(locale).select(count);
const template = messagesForLocale[category] ?? messagesForLocale.other;
return template.replace('{n}', String(count));
}
Đừng nhầm plural rules với number formatting: Format số bằng
NumberFormat, rồi chọn template chuỗi bằngPluralRules. Thư viện như@formatjs/intlcompose cả hai;Intlnative cho bạn primitive.
Intl.Collator — sort theo locale vs .sort() ngây thơ
Array.prototype.sort() mặc định so sánh UTF-16 code unit — Z trước a, dấu xa chữ cơ sở. Với mọi danh sách sort hiển thị user (tên, thành phố, tag), dùng Intl.Collator.
const names = ['Zürich', 'apple', 'Äpfel', 'éclair', 'Zebra'];
// ❌ Code point order — wrong for humans
names.slice().sort();
// → ["Zebra", "Zürich", "apple", "Äpfel", "éclair"]
// ✅ Locale-aware
const collator = new Intl.Collator('de-DE', { sensitivity: 'base' });
names.slice().sort(collator.compare);
// → ["apple", "Äpfel", "éclair", "Zebra", "Zürich"]
sensitivity | Behavior |
|---|---|
'base' | Ignore accents and case — a = ä = A |
'accent' | Distinguish accents, ignore case |
'case' | Distinguish case, ignore accents |
'variant' | Default — distinguish everything including kana variants |
Reuse collator — khởi tạo tốn kém (cùng chủ đề với formatter bên dưới). Tạo một instance per locale lúc init app hoặc lưu trong Map.
Intl.Segmenter — ranh giới grapheme, word, sentence
Độ dài và slice chuỗi nhạy locale khi có emoji, dấu kết hợp, hoặc CJK. "👨👩👧".length là 5 trong JavaScript (UTF-16 code unit) nhưng một grapheme cluster về mặt hiển thị.
const segmenter = new Intl.Segmenter('en-US', { granularity: 'grapheme' });
const segments = [...segmenter.segment('👨👩👧👦 family')];
segments.map((s) => s.segment);
// → ["👨👩👧👦", " ", "f", "a", "m", "i", "l", "y"]
// First entry is ONE user-perceived character
Use case trên sản phẩm thật:
- Bộ đếm ký tự cho post mạng xã hội — đếm grapheme, không phải
.length - Truncate với ellipsis — cắt tại ranh giới grapheme để không split emoji
- Double-click chọn từ —
granularity: 'word'cho CJK và ngôn ngữ châu Âu - Chunk TTS —
granularity: 'sentence'
Hỗ trợ trình duyệt rộng 2025–2026 (Chrome 87+, Firefox 125+, Safari 16.4+). Feature-detect với 'Segmenter' in Intl và fallback thư viện cho browser cũ nếu cần.
Intl.DisplayNames — nhãn enum đọc được
Khi API trả region: 'VN' hoặc language: 'vi', đừng duy trì JSON map tĩnh 200 tên quốc gia.
const regions = new Intl.DisplayNames(['vi-VN'], { type: 'region' });
regions.of('VN'); // → "Việt Nam"
const languages = new Intl.DisplayNames(['en-US'], { type: 'language' });
languages.of('vi'); // → "Vietnamese"
const scripts = new Intl.DisplayNames(['en-US'], { type: 'script' });
scripts.of('Latn'); // → "Latin"
Type gồm region, language, script, currency, calendar, dateTimeField, keyValue (cho cặp key/value Unicode). Kết hợp ListFormat cho "English, Vietnamese, and Japanese" từ mã ISO.
Performance — reuse formatter instance
Tạo Intl.NumberFormat hoặc Intl.DateTimeFormat chậm hơn nhiều bậc so với gọi .format() trên instance có sẵn. Mỗi lần khởi tạo load và parse dữ liệu locale bên trong.
// ❌ Inside a render loop — creates formatter every row
rows.map((row) =>
new Intl.NumberFormat(locale, { style: 'currency', currency: 'USD' }).format(row.price)
);
// ✅ Module-level or memoized cache
const formatterCache = new Map();
function getNumberFormat(locale, options) {
const key = locale + JSON.stringify(options);
if (!formatterCache.has(key)) {
formatterCache.set(key, new Intl.NumberFormat(locale, options));
}
return formatterCache.get(key);
}
Benchmark trên V8 thường cho ~100× chậm hơn construction vs format với NumberFormat. Pattern hoạt động trên production:
- Pre-create formatter cho mỗi locale hỗ trợ lúc bootstrap
- Memoize theo
(locale, optionsKey)khi options thay đổi (toggle currency, date style picker) - Lưu collator và plural rules cùng cách — chúng tốn kém khởi tạo tương đương
- SSR: tạo formatter một lần per request locale trên server, không phải per data row trong loop
Time zone và con đường tới Temporal
Date là object legacy: mutable, tháng 0-indexed, không nhận biết time zone trong object, và parse Date.parse('02/03/2024') phụ thuộc implementation. Intl.DateTimeFormat giải display nhưng không giải số học (cộng 3 tháng, cuối quý ở Tokyo).
Temporal (Stage 3 tính đến 2025–2026) là kế thời: Temporal.Instant, Temporal.ZonedDateTime, Temporal.PlainDate — immutable, time zone rõ ràng, parse không mơ hồ.
// Temporal (where available — polyfill or native in some runtimes)
const zdt = Temporal.ZonedDateTime.from('2025-12-27T15:30:00+07:00[Asia/Ho_Chi_Minh]');
zdt.add({ days: 1 }).toLocaleString('vi-VN');
Trạng thái hiện tại (2025–2026): ship native trên một số runtime JavaScript; browser dùng @js-temporal/polyfill hoặc rollout dần. Chiến lược thực tế hôm nay: lưu chuỗi ISO UTC, format bằng Intl.DateTimeFormat + timeZone, và cô lập date math trong utility đã test cho đến khi Temporal là baseline.
Checklist production
| Task | API |
|---|---|
| Price / invoice line item | Intl.NumberFormat with style: 'currency' |
| KPI / follower count | NumberFormat with notation: 'compact' |
| ”Last updated” timestamp | Intl.RelativeTimeFormat |
| Settings page date | Intl.DateTimeFormat with explicit timeZone |
| Filter tag sort | Intl.Collator |
| ”3 files selected” | Intl.PluralRules + catalog |
| Country dropdown label | Intl.DisplayNames type 'region' |
| Tweet character limit | Intl.Segmenter granularity 'grapheme' |
| Compatible browser list | Intl.ListFormat type 'disjunction' |
Test với locale thật, không chỉ
en-US: Thêmde-DE,ar-EG,ja-JP, và ít nhất một locale plural phức tạp (pl-PL,ar-EG) vào visual regression hoặc Storybook matrix. Đổi locale nên tạo lại formatter cache, không mutate instance cũ.
Điểm chính
- Không tự format separator, vị trí currency, thứ tự ngày, hoặc template plural — quy tắc locale là data, không phải string template.
- Dùng constructor
Intlrõ ràng với options — tránhtoLocaleString()trần không có instance cache, cấu hình sẵn. - Reuse instance — khởi tạo tốn kém; memoize theo locale + options key.
formatToPartsmở UI tùy biến không cần parse mong manh.PluralRulesvàCollatorsửa bug chỉ lộ ra ở locale không phải tiếng Anh.Segmenterlà công cụ đúng cho giới hạn độ dài và truncate với emoji/CJK.- Time zone thuộc formatter options, không phải hack chuỗi — lên kế hoạch
Temporalcho số học.
Demo tương tác trên chạy hoàn toàn trong browser — đổi locale và so sánh en-US, de-DE, vi-VN, ja-JP, và ar-EG cạnh nhau. Với hầu hết frontend app, Intl native cover 90% nhu cầu formatting với zero bundle cost. Chọn @formatjs/intl hoặc polyfill Temporal khi cần message interpolation, số học time zone, hoặc load locale runtime từ CDN.