/ locales/de/common.json
{
"auth": {
"welcome": "Willkommen zurück, {{user}}!",
"signOut": "Abmelden"
},
"notifications": {
"count": "{{count}} Benachrichtigung{{plural}}"
}
}
**Architecture Rationale:** Namespace separation prevents key collisions across large applications. It also allows route-based code splitting, where only the required locale chunks are fetched per view.
### Step 2: Translation Engine & Pluralization
Pluralization must delegate to ICU (International Components for Unicode) rules rather than conditional logic. The following engine resolves keys, interpolates variables, and applies language-specific plural forms.
```typescript
type LocaleData = Record<string, string | Record<string, any>>;
class LocaleEngine {
private resources: Record<string, LocaleData> = {};
private currentLocale: string = 'en';
private fallbackLocale: string = 'en';
register(locale: string, data: LocaleData) {
this.resources[locale] = data;
}
setLocale(locale: string) {
this.currentLocale = locale;
}
resolve(key: string, params: Record<string, any> = {}): string {
const localeData = this.resources[this.currentLocale] || this.resources[this.fallbackLocale];
const keys = key.split('.');
let value: any = localeData;
for (const k of keys) {
if (value && typeof value === 'object') {
value = value[k];
} else {
return key; // Fallback to key if missing
}
}
if (typeof value !== 'string') return key;
// Interpolation
let resolved = value;
for (const [param, val] of Object.entries(params)) {
resolved = resolved.replace(new RegExp(`{{${param}}}`, 'g'), String(val));
}
// Pluralization hook
if (params.count !== undefined) {
resolved = this.applyPluralRules(resolved, params.count);
}
return resolved;
}
private applyPluralRules(template: string, count: number): string {
const pluralForm = this.getPluralCategory(count);
return template.replace('{{plural}}', pluralForm);
}
private getPluralCategory(count: number): string {
// Simplified ICU mapping; production should use Intl.PluralRules
const rules = new Intl.PluralRules(this.currentLocale);
return rules.select(count);
}
}
Architecture Rationale: Intl.PluralRules handles language-specific plural categories (zero, one, two, few, many, other) automatically. Hardcoding count !== 1 fails for Polish, Russian, and Arabic. Delegating to the native API ensures compliance with CLDR standards without manual rule maintenance.
Date, number, and currency formatting should never be manually implemented. The Intl API provides locale-aware formatting that respects regional conventions.
class RegionalFormatter {
static currency(amount: number, currencyCode: string, locale: string): string {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currencyCode,
minimumFractionDigits: 2,
}).format(amount);
}
static date(date: Date, locale: string, options?: Intl.DateTimeFormatOptions): string {
return new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: 'long',
day: 'numeric',
...options,
}).format(date);
}
static number(value: number, locale: string): string {
return new Intl.NumberFormat(locale).format(value);
}
}
Architecture Rationale: Manual formatting breaks across locales. 1234.56 becomes 1.234,56 in German, 1,234.56 in US English, and ١٬٢٣٤٫٥٦ in Arabic. Intl handles digit shaping, grouping separators, and decimal markers natively.
Step 4: Type-Safe Key Resolution
Runtime string resolution is error-prone. TypeScript can enforce key correctness at compile time using template literal types and recursive type mapping.
type Flatten<T, Prefix extends string = ''> = T extends object
? {
[K in keyof T & string]: T[K] extends object
? Flatten<T[K], `${Prefix}${K}.`>
: `${Prefix}${K}`;
}[keyof T & string]
: never;
type LocaleKeys = Flatten<LocaleData>;
// Usage enforcement
function t(key: LocaleKeys, params?: Record<string, any>): string {
return engine.resolve(key, params);
}
Architecture Rationale: Type safety catches missing or mistyped keys during development. It eliminates silent fallbacks to raw keys in production and enables IDE autocomplete for translation namespaces.
Step 5: Build-Time Validation
Missing translations should fail CI/CD pipelines, not production environments. A webpack/Vite plugin can diff locale files and block builds when keys diverge.
// build/validate-locales.ts
import fs from 'fs';
import path from 'path';
function validateLocaleCompleteness(baseLocale: string, targetLocales: string[]) {
const baseKeys = extractKeys(`./locales/${baseLocale}`);
for (const locale of targetLocales) {
const targetKeys = extractKeys(`./locales/${locale}`);
const missing = baseKeys.filter(k => !targetKeys.includes(k));
if (missing.length > 0) {
throw new Error(`Missing keys in ${locale}: ${missing.join(', ')}`);
}
}
}
function extractKeys(dir: string): string[] {
// Recursive JSON parser that flattens nested keys
// Implementation omitted for brevity
return [];
}
Architecture Rationale: Validation at build time prevents partial translations from reaching users. It enforces discipline across teams and integrates seamlessly with existing CI workflows.
Pitfall Guide
1. Assuming Uniform Text Length
Explanation: Developers design UIs around English string lengths. German, Finnish, and Dutch frequently expand 20–50%, causing button overflow, truncated tooltips, and broken grid layouts.
Fix: Use flexible containers, min-width constraints, and CSS logical properties. Implement text-overflow: ellipsis with white-space: nowrap for fixed-width elements. Test layouts with a "pseudo-locale" that artificially inflates strings by 30%.
2. Hardcoding Plural Logic
Explanation: Conditional rendering like count === 1 ? 'item' : 'items' fails for languages with complex plural rules. Polish distinguishes between 1, 2-4, 5-21, 22-24, and 25+.
Fix: Delegate to Intl.PluralRules or a mature i18n library that implements CLDR plural categories. Never write language-specific if/else branches for counts.
3. Ignoring Right-to-Left (RTL) Layouts
Explanation: Arabic and Hebrew require mirrored layouts. Hardcoded margin-left, float: left, or absolute positioning breaks RTL rendering.
Fix: Replace physical CSS properties with logical equivalents (margin-inline-start, padding-inline-end, inset-inline). Toggle dir="rtl" on the root element and let the browser handle mirroring.
4. Skipping Server-Side Localization
Explanation: APIs often return hardcoded error messages or unformatted dates. Client-side i18n cannot fix server payloads.
Fix: Implement locale negotiation middleware that reads Accept-Language headers. Return localized error codes and let the client resolve messages, or pre-format dates/numbers server-side using the detected locale.
5. Missing Fallback Chains
Explanation: When a translation key is missing, the UI displays raw keys or crashes. Users in secondary markets experience broken interfaces.
Fix: Implement a fallback chain: currentLocale -> region fallback (e.g., de-AT -> de) -> base locale (en) -> key string. Log missing keys in development and alert translation teams via CI.
6. Bypassing Type Safety
Explanation: String-based key resolution allows typos and missing namespaces to slip into production. Debugging requires runtime tracing.
Fix: Generate TypeScript types from locale JSON files using a CLI script. Enforce compile-time validation across the codebase. Reject PRs that introduce untyped translation calls.
7. Over-Namespace Fragmentation
Explanation: Creating dozens of micro-namespaces increases cognitive load and complicates lazy loading. Developers struggle to locate keys.
Fix: Group by domain or feature (auth, billing, settings). Limit nesting depth to three levels. Use a centralized key registry for cross-cutting concerns like validation messages.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Small app (<10k lines) | Client-side only with static JSON | Low overhead, fast iteration | Minimal infrastructure cost |
| Enterprise app (>50k lines) | SSR/SSG with dynamic chunk loading | Reduces bundle size, improves TTFB | Moderate build complexity |
| Strict compliance (finance/health) | Compile-time type safety + build validation | Prevents runtime key exposure | Higher initial dev time, lower risk |
| Rapid market expansion | CLI extraction + translation API integration | Automates key sync with vendors | SaaS subscription cost, faster localization |
| Legacy codebase migration | Incremental namespace wrapping + fallback chain | Avoids full rewrite, maintains stability | Phased engineering investment |
Configuration Template
// i18n/engine.ts
import { LocaleEngine } from './LocaleEngine';
import { RegionalFormatter } from './RegionalFormatter';
export const localeEngine = new LocaleEngine();
localeEngine.setLocale('en');
export const t = (key: string, params?: Record<string, any>) =>
localeEngine.resolve(key, params);
export const format = {
currency: (amount: number, currency: string) =>
RegionalFormatter.currency(amount, currency, localeEngine.currentLocale),
date: (date: Date) =>
RegionalFormatter.date(date, localeEngine.currentLocale),
number: (value: number) =>
RegionalFormatter.number(value, localeEngine.currentLocale),
};
// i18n/types.ts
export type LocaleKeys = Flatten<LocaleData>;
export type TranslationParams = Record<string, string | number | boolean>;
// tsconfig.json (add for type generation)
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"paths": {
"@locales/*": ["./locales/*"]
}
}
}
Quick Start Guide
- Initialize Resource Structure: Create
locales/en/ and locales/de/ directories. Add common.json with nested namespaces for auth, UI, and notifications.
- Install/Configure Engine: Drop the
LocaleEngine and RegionalFormatter classes into your project. Register locale JSON files during app bootstrap.
- Replace Hardcoded Strings: Swap inline text with
t('namespace.key') calls. Pass dynamic values via the params object.
- Enable Type Safety: Run a JSON-to-TypeScript generator script. Import the generated
LocaleKeys type and apply it to your translation function signature.
- Validate & Deploy: Add a pre-commit hook that diffs locale files. Commit changes, verify CI passes, and toggle locales in your app settings to confirm rendering.