Skip to main content
Learn how to use Semola’s I18n module for compile-time safe translations with parameter validation.

Overview

The I18n module provides:
  • Fully type-safe translation keys
  • Compile-time parameter validation
  • Type-safe parameter types (string, number, boolean)
  • Nested translation objects
  • Locale fallback to default
  • Zero runtime overhead
  • No dependencies

Basic setup

import { I18n } from "semola/i18n";

const i18n = new I18n({
  defaultLocale: "en",
  locales: {
    en: {
      common: {
        hello: "Hello, world",
        goodbye: "Goodbye",
      },
    },
    es: {
      common: {
        hello: "Hola, mundo",
        goodbye: "Adiós",
      },
    },
  } as const, // Required for type inference
});
The as const assertion is required for proper type inference. Without it, TypeScript won’t be able to validate your translation keys.

Basic translations

Simple strings

i18n.translate("common.hello");
// "Hello, world"

i18n.setLocale("es");
i18n.translate("common.hello");
// "Hola, mundo"

Nested keys

const translations = {
  en: {
    auth: {
      login: "Login",
      logout: "Logout",
      errors: {
        invalidCredentials: "Invalid email or password",
        accountLocked: "Your account has been locked",
      },
    },
  },
} as const;

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

i18n.translate("auth.login"); // "Login"
i18n.translate("auth.errors.invalidCredentials"); // "Invalid email or password"

Parameters

String parameters

const i18n = new I18n({
  defaultLocale: "en",
  locales: {
    en: {
      greeting: "Welcome back, {name:string}!",
    },
    es: {
      greeting: "Bienvenido, {name:string}!",
    },
  } as const,
});

i18n.translate("greeting", { name: "Alice" });
// "Welcome back, Alice!"

i18n.setLocale("es");
i18n.translate("greeting", { name: "María" });
// "Bienvenido, María!"

Multiple parameters

const i18n = new I18n({
  defaultLocale: "en",
  locales: {
    en: {
      notification: "Hello {name:string}, you have {count:number} messages",
    },
  } as const,
});

i18n.translate("notification", { name: "Bob", count: 5 });
// "Hello Bob, you have 5 messages"

Different parameter types

const i18n = new I18n({
  defaultLocale: "en",
  locales: {
    en: {
      status: "Status: {active:boolean}, ID: {id:number}, Name: {name:string}",
    },
  } as const,
});

i18n.translate("status", {
  active: true,
  id: 123,
  name: "Test",
});
// "Status: true, ID: 123, Name: Test"

Parameter syntax

Use {paramName:type} in your translation strings:
  • {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. Invalid parameters won’t compile.

Type safety

Invalid keys don’t compile

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

// ✗ TypeScript error - invalid key
i18n.translate("invalid.key");

Missing parameters don’t compile

const i18n = new I18n({
  defaultLocale: "en",
  locales: {
    en: {
      greeting: "Hello, {name:string}",
    },
  } as const,
});

// ✗ TypeScript error - missing required parameter
i18n.translate("greeting");

// ✓ Correct
i18n.translate("greeting", { name: "Alice" });

Wrong parameter types don’t compile

const i18n = new I18n({
  defaultLocale: "en",
  locales: {
    en: {
      age: "Age: {value:number}",
    },
  } as const,
});

// ✗ TypeScript error - wrong type
i18n.translate("age", { value: "twenty" });

// ✓ Correct
i18n.translate("age", { value: 20 });

Unnecessary parameters don’t compile

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

// ✗ TypeScript error - unnecessary parameter
i18n.translate("hello", { name: "Alice" });

// ✓ Correct
i18n.translate("hello");

Locale management

Getting current locale

const currentLocale = i18n.getLocale();
// "en"

Switching locales

i18n.setLocale("es");
i18n.translate("common.hello");
// "Hola, mundo"

i18n.setLocale("en");
i18n.translate("common.hello");
// "Hello, world"

Type-safe locale switching

// ✓ Only valid locale keys are accepted
i18n.setLocale("en");
i18n.setLocale("es");

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

Fallback behavior

When a translation is missing in the current locale, it falls back to the default locale:
const i18n = new I18n({
  defaultLocale: "en",
  locales: {
    en: {
      greeting: "Hello",
      farewell: "Goodbye",
    },
    es: {
      greeting: "Hola",
      // farewell is missing
    },
  } as const,
});

i18n.setLocale("es");
i18n.translate("greeting"); // "Hola"
i18n.translate("farewell"); // "Goodbye" (fallback to English)

Real-world examples

Application translations

1
Define translations
2
const translations = {
  en: {
    common: {
      save: "Save",
      cancel: "Cancel",
      delete: "Delete",
      loading: "Loading...",
    },
    auth: {
      login: "Login",
      logout: "Logout",
      welcome: "Welcome back, {username:string}!",
      loginSuccess: "Successfully logged in",
      loginFailed: "Login failed",
    },
    errors: {
      notFound: "Page not found",
      serverError: "Server error occurred",
      validation: "Validation failed for {field:string}",
      required: "{field:string} is required",
    },
    profile: {
      age: "Age: {age:number}",
      verified: "Verified: {status:boolean}",
      updatedAt: "Last updated: {date:string}",
    },
  },
  es: {
    common: {
      save: "Guardar",
      cancel: "Cancelar",
      delete: "Eliminar",
      loading: "Cargando...",
    },
    auth: {
      login: "Iniciar sesión",
      logout: "Cerrar sesión",
      welcome: "Bienvenido de nuevo, {username:string}!",
      loginSuccess: "Inicio de sesión exitoso",
      loginFailed: "Inicio de sesión fallido",
    },
    errors: {
      notFound: "Página no encontrada",
      serverError: "Error del servidor",
      validation: "Validación fallida para {field:string}",
      required: "{field:string} es requerido",
    },
    profile: {
      age: "Edad: {age:number}",
      verified: "Verificado: {status:boolean}",
      updatedAt: "Última actualización: {date:string}",
    },
  },
} as const;
3
Create i18n instance
4
const i18n = new I18n({
  defaultLocale: "en",
  locales: translations,
});
5
Use in application
6
// UI labels
const saveButton = i18n.translate("common.save");
const cancelButton = i18n.translate("common.cancel");

// Greetings with parameters
const greeting = i18n.translate("auth.welcome", { username: "John" });
// "Welcome back, John!"

// Error messages
const error = i18n.translate("errors.validation", { field: "email" });
// "Validation failed for email"

// Profile data
const age = i18n.translate("profile.age", { age: 25 });
const verified = i18n.translate("profile.verified", { status: true });

API error messages

import { Api } from "semola/api";
import { I18n } from "semola/i18n";

const i18n = new I18n({
  defaultLocale: "en",
  locales: {
    en: {
      errors: {
        userNotFound: "User not found",
        invalidEmail: "Invalid email address",
        unauthorized: "Unauthorized access",
        serverError: "An unexpected error occurred",
      },
    },
    es: {
      errors: {
        userNotFound: "Usuario no encontrado",
        invalidEmail: "Dirección de correo inválida",
        unauthorized: "Acceso no autorizado",
        serverError: "Ocurrió un error inesperado",
      },
    },
  } as const,
});

const api = new Api();

api.defineRoute({
  path: "/users/:id",
  method: "GET",
  handler: async (c) => {
    const locale = c.req.headers["accept-language"] || "en";
    i18n.setLocale(locale as "en" | "es");

    const user = await getUser(c.req.params.id);

    if (!user) {
      return c.json(404, {
        error: i18n.translate("errors.userNotFound"),
      });
    }

    return c.json(200, user);
  },
});

Form validation

const i18n = new I18n({
  defaultLocale: "en",
  locales: {
    en: {
      validation: {
        required: "{field:string} is required",
        minLength: "{field:string} must be at least {min:number} characters",
        maxLength: "{field:string} must be at most {max:number} characters",
        email: "Please enter a valid email address",
        passwordMatch: "Passwords do not match",
      },
    },
    es: {
      validation: {
        required: "{field:string} es requerido",
        minLength: "{field:string} debe tener al menos {min:number} caracteres",
        maxLength: "{field:string} debe tener como máximo {max:number} caracteres",
        email: "Por favor ingrese un correo válido",
        passwordMatch: "Las contraseñas no coinciden",
      },
    },
  } as const,
});

function validateForm(data: FormData, locale: string) {
  i18n.setLocale(locale as "en" | "es");
  const errors: string[] = [];

  if (!data.email) {
    errors.push(i18n.translate("validation.required", { field: "Email" }));
  }

  if (data.password && data.password.length < 8) {
    errors.push(
      i18n.translate("validation.minLength", {
        field: "Password",
        min: 8,
      })
    );
  }

  return errors;
}

Notification system

type NotificationType = "success" | "error" | "warning" | "info";

const i18n = new I18n({
  defaultLocale: "en",
  locales: {
    en: {
      notifications: {
        userCreated: "User {name:string} created successfully",
        userUpdated: "User profile updated",
        userDeleted: "User deleted",
        emailSent: "Email sent to {email:string}",
        fileUploaded: "Uploaded {count:number} files",
      },
    },
    es: {
      notifications: {
        userCreated: "Usuario {name:string} creado exitosamente",
        userUpdated: "Perfil de usuario actualizado",
        userDeleted: "Usuario eliminado",
        emailSent: "Correo enviado a {email:string}",
        fileUploaded: "{count:number} archivos subidos",
      },
    },
  } as const,
});

function notify(key: string, params?: Record<string, string | number | boolean>) {
  const message = i18n.translate(key as any, params as any);
  showNotification(message);
}

// Usage
notify("notifications.userCreated", { name: "Alice" });
notify("notifications.emailSent", { email: "[email protected]" });
notify("notifications.fileUploaded", { count: 3 });

Middleware integration

import { Api, Middleware } from "semola/api";
import { I18n } from "semola/i18n";

const translations = {
  en: {
    errors: {
      unauthorized: "Unauthorized",
      forbidden: "Forbidden",
    },
  },
  es: {
    errors: {
      unauthorized: "No autorizado",
      forbidden: "Prohibido",
    },
  },
} as const;

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

const localeMiddleware = new Middleware({
  handler: async (c) => {
    const locale = c.req.headers["accept-language"] || "en";
    i18n.setLocale(locale as "en" | "es");

    return { i18n };
  },
});

const api = new Api({
  middlewares: [localeMiddleware] as const,
});

api.defineRoute({
  path: "/protected",
  method: "GET",
  handler: async (c) => {
    const i18n = c.get("i18n");
    const user = await getCurrentUser(c);

    if (!user) {
      return c.json(401, {
        error: i18n.translate("errors.unauthorized"),
      });
    }

    return c.json(200, { data: "protected" });
  },
});

Best practices

Always use as const: This is required for proper type inference. Without it, TypeScript can’t validate your keys.
Organize by feature: Group translations by feature or page for better maintainability.
Use descriptive keys: Make translation keys self-documenting (e.g., auth.loginSuccess instead of msg1).
Keep parameters consistent: Use the same parameter names across locales for the same translation.
Provide fallbacks: Always define all keys in your default locale to ensure fallback works properly.

Loading translations from files

// translations/en.ts
export default {
  common: {
    save: "Save",
    cancel: "Cancel",
  },
  auth: {
    login: "Login",
    logout: "Logout",
  },
} as const;

// translations/es.ts
export default {
  common: {
    save: "Guardar",
    cancel: "Cancelar",
  },
  auth: {
    login: "Iniciar sesión",
    logout: "Cerrar sesión",
  },
} as const;

// i18n.ts
import { I18n } from "semola/i18n";
import en from "./translations/en";
import es from "./translations/es";

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

Next steps

Build docs developers (and LLMs) love