Internationalization (i18n)

Built-in i18n support with automatic locale detection, fallback chains, and JSON-based translation files.

Overview

┌─────────────────────────────────────────────────────────────┐
│                    i18n Request Flow                        │
│                                                             │
│  Request ──▶ Detect Locale ──▶ Load Translations ──▶ Format │
│              │                       │                      │
│              ▼                       ▼                      │
│         1. Accept-Language     en.json                      │
│         2. X-Locale            ms.json                      │
│         3. Cookie              zh.json                      │
│         4. Query param         (fallbacks)                  │
│         5. Default locale                                   │
└─────────────────────────────────────────────────────────────┘

Locale Detection (Priority Order)

function detectLocale(c: Context): string {
  // 1. Query parameter (?lang=ms)
  const queryLang = c.req.query('lang');
  if (queryLang && isSupported(queryLang)) return queryLang;

  // 2. Accept-Language header
  const acceptLang = c.req.header('Accept-Language');
  const matched = matchLocale(acceptLang);
  if (matched) return matched;

  // 3. Cookie (saved preference)
  const cookieLang = getCookie(c, 'locale');
  if (cookieLang && isSupported(cookieLang)) return cookieLang;

  // 4. Default
  return DEFAULT_LOCALE; // 'en'
}

Translation Files

infrastructure/i18n/
├── locales/
│   ├── en.json
│   ├── ms.json
│   └── zh.json
└── index.ts

Example: en.json

{
  "common": {
    "save": "Save",
    "cancel": "Cancel",
    "delete": "Delete",
    "edit": "Edit",
    "loading": "Loading...",
    "error": "An error occurred"
  },
  "auth": {
    "login": {
      "title": "Welcome back",
      "email": "Email address",
      "password": "Password",
      "submit": "Sign in",
      "forgot": "Forgot password?"
    },
    "errors": {
      "invalidCredentials": "Invalid email or password",
      "sessionExpired": "Your session has expired. Please log in again."
    }
  },
  "users": {
    "created": "User created successfully",
    "notFound": "User not found",
    "emailExists": "An account with this email already exists"
  },
  "errors": {
    "validation": {
      "required": "{{field}} is required",
      "email": "Please enter a valid email address",
      "minLength": "{{field}} must be at least {{min}} characters"
    }
  }
}

Example: ms.json

{
  "common": {
    "save": "Simpan",
    "cancel": "Batal",
    "delete": "Padam",
    "edit": "Edit",
    "loading": "Memuatkan...",
    "error": "Ralat berlaku"
  },
  "auth": {
    "login": {
      "title": "Selamat回来",
      "email": "Alamat e-mel",
      "password": "Kata laluan",
      "submit": "Daftar masuk",
      "forgot": "Lupa kata laluan?"
    },
    "errors": {
      "invalidCredentials": "E-mel atau kata laluan tidak sah",
      "sessionExpired": "Sesi anda telah tamat. Sila daftar masuk semula."
    }
  },
  "users": {
    "created": "Pengguna berjaya dibuat",
    "notFound": "Pengguna tidak dijumpai",
    "emailExists": "Akaun dengan e-mel ini sudah wujud"
  },
  "errors": {
    "validation": {
      "required": "{{field}} diperlukan",
      "email": "Sila masukkan alamat e-mel yang sah",
      "minLength": "{{field}} mestilah sekurang-kurangnya {{min}} aksara"
    }
  }
}

Usage

In Use Cases

export class CreateUserUseCase {
  constructor(
    private i18n: I18nService,
  ) {}

  async execute(input: CreateUserInput): Promise<Result> {
    if (await this.userRepo.emailExists(input.email)) {
      // Return localized error
      return Err({
        code: 'EMAIL_EXISTS',
        message: this.i18n.t('users.emailExists'),
      });
    }
    // ...
  }
}

In Middleware

// Attach translator to context
app.use('*', async (c, next) => {
  const locale = detectLocale(c);
  const t = await i18n.createTranslator(locale);
  c.set('t', t);
  await next();
});

In Response Formatting

// Global response formatter
app.use('*', async (c, next) => {
  await next();

  const t = c.get('t');
  const body = c.get('body');

  // Translate error messages in response
  if (body?.error?.code) {
    body.error.message = t(`errors.${body.error.code}`);
  }
});

Interpolation

Support variable substitution:
// Template: "{{field}} is required"
// Input: { field: "Email" }
// Output: "Email is required"

// Multiple variables
// Template: "{{name}} must be at least {{min}} characters"
// Input: { name: "Password", min: 8 }
// Output: "Password must be at least 8 characters"

Pluralization

{
  "messages": {
    "unread": {
      "zero": "No unread messages",
      "one": "{{count}} unread message",
      "other": "{{count}} unread messages"
    }
  }
}
// Usage
t('messages.unread', { count: messages.length });

Fallback Chain

Requested: "zh-CN"


┌─────────┐
│   zh-CN │  (exact match)
└────┬────┘

     ▼ (not found)
┌─────────┐
│   zh    │  (language only)
└────┬────┘

     ▼ (not found)
┌─────────┐
│   en    │  (default)
└─────────┘

Configuration

DEFAULT_LOCALE=en
SUPPORTED_LOCALES=en,ms,zh

Setting Locale

Query Parameter

POST /api/v1/users?lang=ms

Accept-Language Header

Accept-Language: ms, en;q=0.9
// Set locale preference
setCookie(c, 'locale', 'ms', {
  httpOnly: true,
  maxAge: 60 * 60 * 24 * 365, // 1 year
});

Best Practices

  1. Use translation keys — Not raw strings in code
  2. Group by featureauth.login.title, not loginTitle
  3. Include variables — Avoid string concatenation
  4. Plan fallback — Always have a complete default locale (en)
  5. Don’t over-translate — Technical errors can stay in English