Skip to main content

Internationalization (i18n)

A fully type-safe internationalization utility with compile-time validation of translation keys and parameters.

Import

import { I18n } from "semola/i18n";

Class: I18n

Constructor

new I18n<
  const TLocales extends Record<string, Record<string, unknown>>,
  TDefaultLocale extends keyof TLocales
>(config: { defaultLocale: TDefaultLocale; locales: TLocales })
config
object
required
i18n configuration
config.defaultLocale
TDefaultLocale
required
Default locale key (used as fallback and type inference source)
config.locales
TLocales
required
Locale translations object. Must use as const for proper type inference.
Example:
const i18n = new I18n({
  defaultLocale: "en",
  locales: {
    en: {
      common: {
        hello: "Hello, world",
        sayHi: "Hi, {name:string}",
        age: "I am {age:number} years old",
      },
    },
    es: {
      common: {
        hello: "Hola, mundo",
        sayHi: "Hola, {name:string}",
        age: "Tengo {age:number} años",
      },
    },
  } as const, // Required for type inference
});

Methods

translate

Translates a key with optional parameters. Fully type-safe with compile-time validation.
translate<TKey extends NestedKeyOf<TLocales[TDefaultLocale]>>(
  key: TKey,
  ...params: [BuildParamObject<...>] | []
): string
key
TKey
required
Nested translation key (e.g., "common.hello", "auth.welcome"). Only valid keys are accepted.
params
BuildParamObject<...> | []
Parameters object if the translation contains placeholders. TypeScript enforces correct parameter types.
translation
string
Translated string with substituted parameters
Example:
// Basic translation
i18n.translate("common.hello");
// "Hello, world"

// With parameters
i18n.translate("common.sayHi", { name: "Leonardo" });
// "Hi, Leonardo"

i18n.translate("common.age", { age: 25 });
// "I am 25 years old"

// Type errors (won't compile)
i18n.translate("invalid.key");                 // ✗ Invalid key
i18n.translate("common.sayHi");                // ✗ Missing required params
i18n.translate("common.sayHi", { name: 123 }); // ✗ Wrong param type
i18n.translate("common.hello", { name: "x" }); // ✗ Unnecessary params

setLocale

Switches to a different locale.
setLocale(locale: keyof TLocales): void
locale
keyof TLocales
required
Locale key to switch to. Only valid locale keys are accepted.
Example:
i18n.setLocale("es");
i18n.translate("common.hello");
// "Hola, mundo"

i18n.setLocale("invalid"); // ✗ Type error

getLocale

Returns the current locale.
getLocale(): keyof TLocales
locale
keyof TLocales
Current locale key
Example:
const currentLocale = i18n.getLocale();
// "es"

Parameter Syntax

Translation strings support typed parameters with the syntax {paramName:type}:
  • {name:string} - String parameter
  • {age:number} - Number parameter
  • {active:boolean} - Boolean parameter
The type system extracts these at compile time and enforces them in the translate() method. Example:
const translations = {
  en: {
    user: {
      welcome: "Welcome, {name:string}!",
      age: "Age: {age:number}",
      status: "Active: {active:boolean}",
    },
  },
} as const;

const i18n = new I18n({ defaultLocale: "en", locales: translations });

// TypeScript enforces parameter types
i18n.translate("user.welcome", { name: "Alice" });     // ✓
i18n.translate("user.age", { age: 30 });                // ✓
i18n.translate("user.status", { active: true });        // ✓
i18n.translate("user.welcome", { name: 123 });          // ✗ Type error

Type Definitions

NestedKeyOf

Extracts all valid nested keys from a translation object.
type NestedKeyOf<T> = ...; // Internal type
Generates keys like "common.hello", "auth.login.success", etc.

BuildParamObject

Extracts parameter types from a template string.
type BuildParamObject<T extends string> = ...;
Converts "Hi, {name:string}" to { name: string }, etc.

GetNestedValue

Retrieves the value at a nested key path.
type GetNestedValue<T, K extends string> = ...;

Usage Examples

Multi-language Application

import { I18n } from "semola/i18n";

const translations = {
  en: {
    auth: {
      welcome: "Welcome back, {name:string}!",
      loginSuccess: "Successfully logged in",
      loginFailed: "Login failed",
    },
    profile: {
      age: "Age: {age:number}",
      verified: "Verified: {status:boolean}",
    },
  },
  es: {
    auth: {
      welcome: "Bienvenido, {name:string}!",
      loginSuccess: "Inicio de sesión exitoso",
      loginFailed: "Inicio de sesión fallido",
    },
    profile: {
      age: "Edad: {age:number}",
      verified: "Verificado: {status:boolean}",
    },
  },
} as const;

const i18n = new I18n({
  defaultLocale: "en",
  locales: translations,
});

// Use in your app
function greetUser(name: string) {
  return i18n.translate("auth.welcome", { name });
}

function showProfile(age: number, verified: boolean) {
  console.log(i18n.translate("profile.age", { age }));
  console.log(i18n.translate("profile.verified", { status: verified }));
}

// Switch language
i18n.setLocale("es");
greetUser("Maria"); // "Bienvenido, Maria!"

API Error Messages

import { I18n } from "semola/i18n";

const errors = {
  en: {
    validation: {
      required: "{field:string} is required",
      minLength: "{field:string} must be at least {min:number} characters",
      email: "Invalid email address",
    },
    auth: {
      unauthorized: "Unauthorized access",
      tokenExpired: "Your session has expired",
    },
  },
  fr: {
    validation: {
      required: "{field:string} est requis",
      minLength: "{field:string} doit contenir au moins {min:number} caractères",
      email: "Adresse e-mail invalide",
    },
    auth: {
      unauthorized: "Accès non autorisé",
      tokenExpired: "Votre session a expiré",
    },
  },
} as const;

const i18n = new I18n({ defaultLocale: "en", locales: errors });

// Use in validation
function validateField(field: string, value: string, minLength: number) {
  if (!value) {
    throw new Error(i18n.translate("validation.required", { field }));
  }
  
  if (value.length < minLength) {
    throw new Error(
      i18n.translate("validation.minLength", { field, min: minLength })
    );
  }
}

With User Preferences

import { I18n } from "semola/i18n";

class UserSession {
  private i18n: I18n<typeof translations, "en">;
  
  constructor(userLocale: "en" | "es" | "fr") {
    this.i18n = new I18n({
      defaultLocale: "en",
      locales: translations,
    });
    this.i18n.setLocale(userLocale);
  }
  
  t(key: string, params?: any) {
    return this.i18n.translate(key as any, params);
  }
  
  changeLanguage(locale: "en" | "es" | "fr") {
    this.i18n.setLocale(locale);
  }
}

// Usage
const session = new UserSession("es");
console.log(session.t("common.hello")); // "Hola, mundo"

session.changeLanguage("fr");
console.log(session.t("common.hello")); // "Bonjour, monde"

Nested Translations

const translations = {
  en: {
    app: {
      nav: {
        home: "Home",
        profile: "Profile",
        settings: "Settings",
      },
      footer: {
        copyright: "© {year:number} Company Name",
        privacy: "Privacy Policy",
      },
    },
  },
} as const;

const i18n = new I18n({ defaultLocale: "en", locales: translations });

// Deeply nested keys are fully typed
i18n.translate("app.nav.home");                        // ✓ "Home"
i18n.translate("app.footer.copyright", { year: 2024 }); // ✓ "© 2024 Company Name"
i18n.translate("app.invalid.key");                      // ✗ Type error

Features

  • Type-safe keys: Only valid nested keys accepted (e.g., "common.hello")
  • Type-safe parameters: Parameter types validated at compile time
  • Type-safe locales: Only defined locale keys can be set
  • Nested translations: Support for deeply nested translation objects
  • Locale fallback: Falls back to default locale if translation missing
  • Zero runtime overhead: No runtime type checking - pure TypeScript validation
  • Const assertion required: Use as const on locale objects for proper type inference

Important Notes

Const Assertion Required

You must use as const on the locales object for proper type inference:
// ✓ Correct
const i18n = new I18n({
  defaultLocale: "en",
  locales: {
    en: { hello: "Hello" },
    es: { hello: "Hola" },
  } as const, // Required!
});

// ✗ Incorrect (no type safety)
const i18n = new I18n({
  defaultLocale: "en",
  locales: {
    en: { hello: "Hello" },
    es: { hello: "Hola" },
  }, // Missing as const
});

Locale Fallback

If a translation is missing in the current locale, the default locale is used as fallback:
const i18n = new I18n({
  defaultLocale: "en",
  locales: {
    en: { greeting: "Hello" },
    es: {}, // Missing translation
  } as const,
});

i18n.setLocale("es");
i18n.translate("greeting"); // Falls back to "Hello"

Build docs developers (and LLMs) love