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
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;
const i18n = new I18n({
defaultLocale: "en",
locales: translations,
});
// 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);
},
});
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