Internationalization and Localization
Design applications that work for users in any language and cultural context.
TL;DR
Internationalization (i18n) is designing code to support multiple languages. Localization (l10n) is translating and adapting content for specific regions. Never hardcode strings in code—externalize them to translation files. Use Unicode throughout your system. Format numbers, dates, and currencies based on locale, not hardcoded patterns. Pluralize correctly (some languages have complex plural rules). Never concatenate strings; use proper templating. Plan for text expansion: translations are often 30-40% longer than English. Test with right-to-left languages. Internationalization isn't an afterthought—it should be built in from the start.
Learning Objectives
- Distinguish between internationalization (code) and localization (content)
- Externalize strings to translation files
- Handle plural forms, formatting, and text direction properly
- Design systems that accommodate text expansion
- Implement locale-aware number, date, and currency formatting
- Test applications with multiple languages and writing systems
Motivating Scenario
A company launches in the US with only English. Two years later, they want to expand to Japan. They discover strings hardcoded throughout the codebase: UI labels, error messages, placeholders. Engineers now must hunt down every string, carefully extract it, and coordinate with translators. Deployment breaks because a string was missed. Database fields assume Latin characters, failing on Japanese. Dates format as MM/DD/YYYY everywhere instead of respecting locale. A proper internationalization strategy from day one would have prevented these problems.
Core Concepts
Internationalization vs. Localization
Internationalization is the engineering work: external strings, Unicode support, formatting rules, right-to-left handling. Localization is the content work: translation, cultural adaptation, regional imagery. Both are necessary for global applications.
Message Keys and Translation Files
Use unique keys to identify strings, not the English text itself. This allows languages to change independently without code changes. Store translations in files (JSON, YAML, XLIFF) that translators can modify.
Locale-Aware Formatting
Numbers, dates, currencies, and units format differently by locale. Use locale libraries (ICU, Intl) rather than hardcoding patterns. Handle pluralization rules—English has 2 forms (singular/plural), but some languages have 6+ forms.
Text Expansion
Translated text is often longer: German translations are ~35% longer, Arabic ~20% shorter. Layout must accommodate variable-length text, not hardcoded widths.
Practical Example
- Python
- Go
- Node.js
# ❌ POOR - Hardcoded strings, no translation support
def get_user_message(user_name, item_count):
if item_count == 1:
return f"Hello {user_name}, you have 1 item"
else:
return f"Hello {user_name}, you have {item_count} items"
def format_price(amount):
return f"${amount:.2f}" # Always USD format!
# ✅ EXCELLENT - Externalized strings with proper i18n
from babel.support import Translations
from babel import Locale
import json
class MessageCatalog:
"""Manage translations using ICU/Babel."""
def __init__(self, locale_code='en_US'):
self.locale = Locale.parse(locale_code)
self.messages = self._load_translations(locale_code)
def _load_translations(self, locale_code):
"""Load translation file for locale."""
try:
with open(f'locales/{locale_code}.json', 'r', encoding='utf-8') as f:
return json.load(f)
except FileNotFoundError:
return {}
def get(self, key, **kwargs):
"""Get translated message with interpolation."""
template = self.messages.get(key, key)
return template.format(**kwargs)
def get_plural(self, key, count, **kwargs):
"""Get translated message with plural handling."""
# ICU pluralization rules
plural_key = f"{key}_{self._get_plural_form(count)}"
return self.get(plural_key, count=count, **kwargs)
def _get_plural_form(self, count):
"""Get plural form for locale (one, few, many, other)."""
if self.locale.language == 'en':
return 'other' if count != 1 else 'one'
# Simplified; actual rules use CLDR
return 'other'
def format_currency(self, amount):
"""Format currency for locale."""
from babel.numbers import format_currency
return format_currency(amount, self.locale.currency,
locale=self.locale)
def format_date(self, date_obj):
"""Format date for locale."""
from babel.dates import format_date
return format_date(date_obj, locale=self.locale)
# Translation files
# locales/en_US.json
{
"greeting": "Hello {name}",
"items_one": "You have {count} item",
"items_other": "You have {count} items",
"purchase_complete": "Thank you for your purchase!"
}
# locales/fr_FR.json
{
"greeting": "Bonjour {name}",
"items_one": "Vous avez {count} article",
"items_other": "Vous avez {count} articles",
"purchase_complete": "Merci pour votre achat!"
}
# locales/ja_JP.json
{
"greeting": "こんにちは{name}さん",
"items_one": "{count}個のアイテムがあります",
"items_other": "{count}個のアイテムがあります",
"purchase_complete": "ご購入ありがとうございます!"
}
# Usage
def get_user_message(user_name, item_count, locale='en_US'):
catalog = MessageCatalog(locale)
greeting = catalog.get('greeting', name=user_name)
items = catalog.get_plural('items', item_count, count=item_count)
return f"{greeting}, {items}"
def format_price(amount, locale='en_US'):
catalog = MessageCatalog(locale)
return catalog.format_currency(amount)
# Works for any language/locale without code changes
print(get_user_message("Alice", 3, "en_US")) # Hello Alice, you have 3 items
print(get_user_message("Alice", 3, "fr_FR")) # Bonjour Alice, vous avez 3 articles
// ❌ POOR - Hardcoded strings and formatting
func GetUserMessage(name string, count int) string {
if count == 1 {
return fmt.Sprintf("Hello %s, you have 1 item", name)
}
return fmt.Sprintf("Hello %s, you have %d items", name, count)
}
func FormatPrice(amount float64) string {
return fmt.Sprintf("$%.2f", amount) // Always USD!
}
// ✅ EXCELLENT - Externalized strings with i18n support
package i18n
import (
"encoding/json"
"fmt"
"os"
"golang.org/x/text/language"
"golang.org/x/text/message"
)
type MessageCatalog struct {
locale language.Tag
messages map[string]string
plurals map[string]map[string]string
}
func NewMessageCatalog(localeCode string) (*MessageCatalog, error) {
tag := language.MustParse(localeCode)
mc := &MessageCatalog{
locale: tag,
messages: make(map[string]string),
plurals: make(map[string]map[string]string),
}
// Load translations
data, err := os.ReadFile(fmt.Sprintf("locales/%s.json", localeCode))
if err != nil {
return nil, err
}
var translations map[string]interface{}
if err := json.Unmarshal(data, &translations); err != nil {
return nil, err
}
for key, value := range translations {
if str, ok := value.(string); ok {
mc.messages[key] = str
} else if pluralMap, ok := value.(map[string]interface{}); ok {
mc.plurals[key] = make(map[string]string)
for form, trans := range pluralMap {
if str, ok := trans.(string); ok {
mc.plurals[key][form] = str
}
}
}
}
return mc, nil
}
func (mc *MessageCatalog) Get(key string, args ...interface{}) string {
if template, exists := mc.messages[key]; exists {
return fmt.Sprintf(template, args...)
}
return key
}
func (mc *MessageCatalog) GetPlural(key string, count int, args ...interface{}) string {
pluralForms, exists := mc.plurals[key]
if !exists {
return key
}
form := "other"
if count == 1 && mc.locale.String() == "en-US" {
form = "one"
}
if template, exists := pluralForms[form]; exists {
finalArgs := []interface{}{count}
finalArgs = append(finalArgs, args...)
return fmt.Sprintf(template, finalArgs...)
}
return key
}
func (mc *MessageCatalog) FormatCurrency(amount float64) string {
// Use CLDR currency formatting
switch mc.locale.String() {
case "fr-FR":
return fmt.Sprintf("%.2f €", amount)
case "ja-JP":
return fmt.Sprintf("¥%.0f", amount)
default:
return fmt.Sprintf("$%.2f", amount)
}
}
// Usage
catalog, _ := NewMessageCatalog("en-US")
fmt.Println(catalog.GetPlural("items", 3, 3)) // You have 3 items
fmt.Println(catalog.FormatCurrency(99.99)) // $99.99
// ❌ POOR - Hardcoded strings, no i18n
function getUserMessage(userName, itemCount) {
if (itemCount === 1) {
return `Hello ${userName}, you have 1 item`;
}
return `Hello ${userName}, you have ${itemCount} items`;
}
function formatPrice(amount) {
return `$${amount.toFixed(2)}`; // Always USD!
}
// ✅ EXCELLENT - i18n with translation files and locale formatting
class I18n {
constructor(locale = 'en-US') {
this.locale = new Intl.Locale(locale);
this.messages = {};
this.pluralRules = new Intl.PluralRules(locale);
}
async loadTranslations(localeCode) {
try {
const response = await fetch(`/locales/${localeCode}.json`);
this.messages = await response.json();
} catch (error) {
console.warn(`Failed to load translations for ${localeCode}`);
}
}
get(key, params = {}) {
let message = this.messages[key] || key;
// Simple template replacement
Object.entries(params).forEach(([k, v]) => {
message = message.replace(`{${k}}`, v);
});
return message;
}
getPlural(key, count, params = {}) {
const pluralForm = this.pluralRules.select(count);
const pluralKey = `${key}_${pluralForm}`;
return this.get(pluralKey, { count, ...params });
}
formatCurrency(amount) {
return new Intl.NumberFormat(this.locale.baseName, {
style: 'currency',
currency: this.locale.currency || 'USD'
}).format(amount);
}
formatDate(date) {
return new Intl.DateTimeFormat(this.locale.baseName).format(date);
}
formatNumber(number) {
return new Intl.NumberFormat(this.locale.baseName).format(number);
}
}
// Translation files
// locales/en-US.json
{
"greeting": "Hello {name}",
"items_one": "You have {count} item",
"items_other": "You have {count} items",
"price_label": "Price",
"purchase_button": "Buy Now"
}
// locales/fr-FR.json
{
"greeting": "Bonjour {name}",
"items_one": "Vous avez {count} article",
"items_other": "Vous avez {count} articles",
"price_label": "Prix",
"purchase_button": "Acheter maintenant"
}
// Usage
const i18n = new I18n('en-US');
await i18n.loadTranslations('en-US');
console.log(i18n.get('greeting', { name: 'Alice' }));
// "Hello Alice"
console.log(i18n.getPlural('items', 3, { count: 3 }));
// "You have 3 items"
console.log(i18n.formatCurrency(99.99));
// "$99.99" (en-US) or "99,99 €" (fr-FR)
console.log(i18n.formatDate(new Date()));
// Locale-specific date format
Internationalization Patterns
Message Key Structure
// Use consistent key naming for organization
{
"ui.button.submit": "Submit",
"ui.button.cancel": "Cancel",
"errors.validation.email": "Invalid email format",
"errors.validation.required": "This field is required",
"success.payment.completed": "Payment processed successfully"
}
Handling Text Direction (RTL)
function getTextDirection(locale) {
const rtlLanguages = ['ar', 'he', 'fa', 'ur'];
const language = locale.split('-')[0];
return rtlLanguages.includes(language) ? 'rtl' : 'ltr';
}
// CSS
document.documentElement.dir = getTextDirection('ar-SA');
document.documentElement.lang = 'ar-SA';
Plural Rules
// Complex plural rules (e.g., Polish: 1, few, many, other)
const pluralRules = {
'en': { 1: 'one', DEFAULT: 'other' },
'pl': {
1: 'one',
(n) => n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20): 'few',
DEFAULT: 'other'
}
};
Text Expansion Handling
/* Layout accommodates text expansion */
.button {
padding: 8px 16px; /* Not tight */
min-width: fit-content; /* Flexible width */
word-break: break-word; /* Allow wrapping */
}
/* Avoid fixed widths */
.form-label {
width: auto; /* Not fixed px */
max-width: 100%;
}
Design Review Checklist
- Are all user-visible strings externalized from code?
- Are strings organized with consistent naming schemes?
- Does the system handle pluralization correctly for all target languages?
- Are dates, numbers, and currencies formatted locale-specifically?
- Is text direction (RTL) supported for relevant languages?
- Has the UI been tested with text expansion (especially for long languages)?
- Are all Unicode characters properly handled in data storage and display?
- Can the system load translations without recompiling?
Self-Check
-
Find a hardcoded string in your application. How would you externalize it?
-
What languages does your application target? What are their plural rules?
-
How would you handle a UI element with a fixed width when supporting multiple languages?
Internationalization is infrastructure work: designing for multiple languages from the start. Localization is content work: translating and adapting. Don't hardcode strings. Use translation files with consistent key naming. Format dates, numbers, and currencies using locale-aware libraries. Expect 30-40% text expansion for translations. Support right-to-left languages. If you design for English only, expanding to other languages becomes painfully expensive. Build i18n in from the beginning.
Next Steps
- Learn about naming conventions ↗ for creating consistent translation keys
- Explore validation ↗ for handling multi-language input
- Study accessibility ↗ which complements localization for inclusive design
- Review general principles ↗ for designing systems that extend globally
References
- Unicode Consortium. (2024). The Unicode Standard. Retrieved from https://unicode.org/
- CLDR. (2024). Common Locale Data Repository. Retrieved from https://cldr.unicode.org/
- W3C. (2021). Web Content Accessibility Guidelines (WCAG) 2.1. Retrieved from https://www.w3.org/WAI/WCAG21/
- Esselink, B. (2000). A Practical Guide to Localization. John Benjamins.