JavaScript Intl API: Internationalization

Building applications for a global audience means more than translating strings. Numbers, dates, currencies, and even alphabetical sorting work differently across cultures. The JavaScript Intl API...

Key Insights

  • The Intl API provides native JavaScript internationalization without external libraries, supporting number formatting, date/time handling, string collation, and more across 100+ locales with excellent browser support.
  • Creating formatters is expensive—instantiate them once and reuse them throughout your application rather than creating new instances for each operation to avoid significant performance penalties.
  • Proper internationalization goes beyond translation; it requires locale-aware number formatting, culturally appropriate date displays, correct string sorting, and language-specific pluralization rules.

Introduction to the Intl API

Building applications for a global audience means more than translating strings. Numbers, dates, currencies, and even alphabetical sorting work differently across cultures. The JavaScript Intl API provides a standardized way to handle these variations without reaching for heavyweight libraries like Moment.js or external localization services.

The Intl API has been part of the ECMAScript standard since 2012 and enjoys near-universal browser support. Modern browsers include locale data for over 100 languages, making it a reliable choice for production applications.

Here’s why you should care:

// The old way: brittle and culture-blind
const date = new Date('2024-01-15');
const manual = `${date.getMonth() + 1}/${date.getDate()}/${date.getFullYear()}`;
console.log(manual); // "1/15/2024" - assumes US format

// The Intl way: locale-aware and robust
const formatter = new Intl.DateTimeFormat('en-US');
console.log(formatter.format(date)); // "1/15/2024"

const deFormatter = new Intl.DateTimeFormat('de-DE');
console.log(deFormatter.format(date)); // "15.1.2024"

const jaFormatter = new Intl.DateTimeFormat('ja-JP');
console.log(jaFormatter.format(date)); // "2024/1/15"

The Intl approach automatically handles cultural conventions. You focus on what to display; the browser handles how to display it.

Number Formatting with Intl.NumberFormat

Numbers look deceptively simple until you realize that Europeans use commas for decimals, Indians group digits differently than Americans, and Japanese yen doesn’t use decimal places at all.

Intl.NumberFormat handles all of this:

const amount = 1234567.89;

// Currency formatting across locales
const usdFormatter = new Intl.NumberFormat('en-US', {
  style: 'currency',
  currency: 'USD'
});
console.log(usdFormatter.format(amount)); // "$1,234,567.89"

const eurFormatter = new Intl.NumberFormat('de-DE', {
  style: 'currency',
  currency: 'EUR'
});
console.log(eurFormatter.format(amount)); // "1.234.567,89 €"

const jpyFormatter = new Intl.NumberFormat('ja-JP', {
  style: 'currency',
  currency: 'JPY'
});
console.log(jpyFormatter.format(amount)); // "¥1,234,568" (rounded, no decimals)

Notice how the Japanese formatter automatically rounds to whole numbers because yen doesn’t use fractional units. This cultural knowledge is baked into the locale data.

For percentages and units:

// Percentage formatting
const percentFormatter = new Intl.NumberFormat('en-US', {
  style: 'percent',
  minimumFractionDigits: 2,
  maximumFractionDigits: 2
});
console.log(percentFormatter.format(0.1547)); // "15.47%"

// Unit formatting
const tempFormatter = new Intl.NumberFormat('en-US', {
  style: 'unit',
  unit: 'celsius',
  unitDisplay: 'long'
});
console.log(tempFormatter.format(22)); // "22 degrees Celsius"

// Compact notation for large numbers
const compactFormatter = new Intl.NumberFormat('en-US', {
  notation: 'compact',
  compactDisplay: 'short'
});
console.log(compactFormatter.format(1500000)); // "1.5M"
console.log(compactFormatter.format(2400)); // "2.4K"

The compact notation is perfect for dashboards and social media-style count displays.

Date and Time Formatting with Intl.DateTimeFormat

Date formatting is notoriously complex. The US writes “12/25/2024”, Europe writes “25/12/2024”, and ISO 8601 demands “2024-12-25”. Then add time zones, 12/24-hour clocks, and calendar systems.

Intl.DateTimeFormat handles all of it:

const date = new Date('2024-12-25T15:30:00Z');

// Different locale formats
const usFormat = new Intl.DateTimeFormat('en-US', {
  year: 'numeric',
  month: 'long',
  day: 'numeric',
  hour: 'numeric',
  minute: 'numeric',
  timeZone: 'America/New_York'
});
console.log(usFormat.format(date)); // "December 25, 2024 at 10:30 AM"

const frFormat = new Intl.DateTimeFormat('fr-FR', {
  dateStyle: 'full',
  timeStyle: 'short',
  timeZone: 'Europe/Paris'
});
console.log(frFormat.format(date)); // "mercredi 25 décembre 2024 à 16:30"

// Compact date style
const shortFormat = new Intl.DateTimeFormat('en-GB', {
  dateStyle: 'short'
});
console.log(shortFormat.format(date)); // "25/12/2024"

The dateStyle and timeStyle options provide convenient presets. For more control, specify individual components.

Time zone handling is straightforward:

const now = new Date();

const tokyoTime = new Intl.DateTimeFormat('en-US', {
  timeZone: 'Asia/Tokyo',
  hour: 'numeric',
  minute: 'numeric',
  second: 'numeric',
  timeZoneName: 'short'
});

const londonTime = new Intl.DateTimeFormat('en-US', {
  timeZone: 'Europe/London',
  hour: 'numeric',
  minute: 'numeric',
  second: 'numeric',
  timeZoneName: 'short'
});

console.log(`Tokyo: ${tokyoTime.format(now)}`);
console.log(`London: ${londonTime.format(now)}`);

For relative time (“2 days ago”, “in 3 hours”), use Intl.RelativeTimeFormat:

const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });

console.log(rtf.format(-1, 'day')); // "yesterday"
console.log(rtf.format(2, 'day')); // "in 2 days"
console.log(rtf.format(-3, 'month')); // "3 months ago"

String Comparison and Sorting with Intl.Collator

Sorting strings alphabetically seems trivial until you encounter languages with accented characters, case sensitivity rules, or entirely different alphabets. JavaScript’s default string comparison doesn’t understand cultural sorting conventions.

Intl.Collator fixes this:

const names = ['Émilie', 'Zoe', 'Éric', 'Alice', 'Zoë'];

// Default JavaScript sort (wrong for many locales)
console.log([...names].sort());
// ['Alice', 'Zoe', 'Zoë', 'Émilie', 'Éric']

// Locale-aware French sort
const frCollator = new Intl.Collator('fr');
console.log([...names].sort(frCollator.compare));
// ['Alice', 'Émilie', 'Éric', 'Zoe', 'Zoë']

// Case-insensitive comparison
const caseInsensitive = new Intl.Collator('en', {
  sensitivity: 'base'
});
console.log(caseInsensitive.compare('Resume', 'résumé')); // 0 (equal)

For numeric strings, enable numeric sorting:

const versions = ['v1.10', 'v1.2', 'v1.100', 'v1.20'];

// Default sort (wrong)
console.log([...versions].sort());
// ['v1.10', 'v1.100', 'v1.2', 'v1.20']

// Numeric collation (correct)
const numericCollator = new Intl.Collator('en', {
  numeric: true
});
console.log([...versions].sort(numericCollator.compare));
// ['v1.2', 'v1.10', 'v1.20', 'v1.100']

This is essential for version numbers, file names, and any alphanumeric identifiers.

Plural Rules and List Formatting

English has simple pluralization: one item vs. many items. Other languages have more complex rules. Arabic has six plural forms. Polish has different forms for 2-4 items versus 5+ items.

Intl.PluralRules encapsulates these rules:

const enPlural = new Intl.PluralRules('en');
console.log(enPlural.select(0)); // "other"
console.log(enPlural.select(1)); // "one"
console.log(enPlural.select(2)); // "other"

const plPlural = new Intl.PluralRules('pl');
console.log(plPlural.select(1)); // "one"
console.log(plPlural.select(2)); // "few"
console.log(plPlural.select(5)); // "many"

// Practical usage
function formatItemCount(count, locale = 'en') {
  const pluralRules = new Intl.PluralRules(locale);
  const pluralForm = pluralRules.select(count);
  
  const translations = {
    en: { one: 'item', other: 'items' },
    pl: { one: 'przedmiot', few: 'przedmioty', many: 'przedmiotów' }
  };
  
  return `${count} ${translations[locale][pluralForm]}`;
}

console.log(formatItemCount(1, 'en')); // "1 item"
console.log(formatItemCount(5, 'en')); // "5 items"
console.log(formatItemCount(2, 'pl')); // "2 przedmioty"
console.log(formatItemCount(5, 'pl')); // "5 przedmiotów"

Intl.ListFormat handles joining lists with locale-appropriate conjunctions:

const items = ['apples', 'oranges', 'bananas'];

// Conjunction (and)
const andFormatter = new Intl.ListFormat('en', {
  style: 'long',
  type: 'conjunction'
});
console.log(andFormatter.format(items)); // "apples, oranges, and bananas"

// Disjunction (or)
const orFormatter = new Intl.ListFormat('en', {
  style: 'long',
  type: 'disjunction'
});
console.log(orFormatter.format(items)); // "apples, oranges, or bananas"

// Spanish conjunction
const esFormatter = new Intl.ListFormat('es', {
  style: 'long',
  type: 'conjunction'
});
console.log(esFormatter.format(items)); // "apples, oranges y bananas"

Best Practices and Performance Considerations

Creating Intl formatters is expensive. Each instantiation parses locale data and builds formatting rules. In tight loops or frequently called functions, this becomes a bottleneck.

The solution: create formatters once and reuse them:

// Bad: creates formatter on every call
function formatCurrency(amount) {
  const formatter = new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: 'USD'
  });
  return formatter.format(amount);
}

// Good: reuse formatter instance
const currencyFormatter = new Intl.NumberFormat('en-US', {
  style: 'currency',
  currency: 'USD'
});

function formatCurrency(amount) {
  return currencyFormatter.format(amount);
}

// Better: memoized factory pattern
const formatters = new Map();

function getFormatter(locale, options) {
  const key = `${locale}-${JSON.stringify(options)}`;
  
  if (!formatters.has(key)) {
    formatters.set(key, new Intl.NumberFormat(locale, options));
  }
  
  return formatters.get(key);
}

function formatPrice(amount, locale = 'en-US', currency = 'USD') {
  const formatter = getFormatter(locale, {
    style: 'currency',
    currency
  });
  return formatter.format(amount);
}

This memoization pattern works for all Intl formatters and can reduce formatting time by 10-100x in high-volume scenarios.

Always provide fallback locales for unsupported regions:

function getSupportedLocale(preferredLocale) {
  const supported = ['en-US', 'es-ES', 'fr-FR', 'de-DE'];
  
  if (supported.includes(preferredLocale)) {
    return preferredLocale;
  }
  
  // Check language without region
  const language = preferredLocale.split('-')[0];
  const match = supported.find(loc => loc.startsWith(language));
  
  return match || 'en-US'; // fallback to English
}

Conclusion

The Intl API transforms internationalization from a complex, error-prone task into straightforward browser-native functionality. You get proper number formatting, culturally appropriate date displays, correct string sorting, and language-specific pluralization without external dependencies.

Start with Intl.NumberFormat and Intl.DateTimeFormat for immediate impact. Add Intl.Collator when sorting user-generated content. Layer in Intl.PluralRules and Intl.ListFormat as your localization needs grow.

Remember to cache formatter instances and provide sensible fallbacks. With these practices, you’ll build applications that feel native to users worldwide—no matter where they are or what language they speak.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.