jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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.

ConcernHand-rolled trapWhat Intl handles
Decimal separatorAlways .. in en-US, , in de-DE, ٫ in ar-EG
GroupingAlways , every 3 digits1,234,567 vs 1.234.567 vs 12,34,567 (Indian lakhs)
Currency symbolPrefix $$1.00 (en-US) vs 1,00 $ (fr-FR) vs USD 1.00 (some locales)
PercentAppend %-12% vs -12 % vs -12٪ depending on locale
Date orderMM/DD/YYYY27/12/2025 (vi-VN), 2025/12/27 (ja-JP), non-Gregorian calendars
RTLIgnore directionArabic and Hebrew need symbol placement and bidirectional text
Pluralization=== 1 checkone, few, many, other categories per locale
Sort orderUnicode code pointsAccent-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 constructor Intl.* 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)
OptionUse case
minimumFractionDigits / maximumFractionDigitsCrypto (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

PitfallReality
Date is always UTC internallyDisplay layer must set timeZone explicitly — default is runtime local zone
DST gaps and overlaps2025-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 qua Intl.DateTimeFormat hoặc formatToParts.


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ằng PluralRules. Thư viện như @formatjs/intl compose cả hai; Intl native 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"]
sensitivityBehavior
'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. "👨‍👩‍👧".length5 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 TTSgranularity: '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:

  1. Pre-create formatter cho mỗi locale hỗ trợ lúc bootstrap
  2. Memoize theo (locale, optionsKey) khi options thay đổi (toggle currency, date style picker)
  3. Lưu collator và plural rules cùng cách — chúng tốn kém khởi tạo tương đương
  4. 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

TaskAPI
Price / invoice line itemIntl.NumberFormat with style: 'currency'
KPI / follower countNumberFormat with notation: 'compact'
”Last updated” timestampIntl.RelativeTimeFormat
Settings page dateIntl.DateTimeFormat with explicit timeZone
Filter tag sortIntl.Collator
”3 files selected”Intl.PluralRules + catalog
Country dropdown labelIntl.DisplayNames type 'region'
Tweet character limitIntl.Segmenter granularity 'grapheme'
Compatible browser listIntl.ListFormat type 'disjunction'

Test với locale thật, không chỉ en-US: Thêm de-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

  1. 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.
  2. Dùng constructor Intl rõ ràng với options — tránh toLocaleString() trần không có instance cache, cấu hình sẵn.
  3. Reuse instance — khởi tạo tốn kém; memoize theo locale + options key.
  4. formatToParts mở UI tùy biến không cần parse mong manh.
  5. PluralRulesCollator sửa bug chỉ lộ ra ở locale không phải tiếng Anh.
  6. Segmenter là công cụ đúng cho giới hạn độ dài và truncate với emoji/CJK.
  7. Time zone thuộc formatter options, không phải hack chuỗi — lên kế hoạch Temporal cho 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.