Internationalization
Stratal includes a built-in internationalization (i18n) module that provides locale detection, type-safe translation keys, ICU MessageFormat interpolation, and automatic translation of validation errors. Messages are registered per module under a keyed namespace, so any feature module or package can ship its own translations.
The I18nModule exposes two static methods. forRoot() configures locale settings. registerMessages() adds translations and can be called from any module.
import { Module } from 'stratal/module'import { I18nModule } from 'stratal/i18n'
@Module({ imports: [ I18nModule.forRoot({ defaultLocale: 'en', fallbackLocale: 'en', locales: ['en', 'fr'], }), I18nModule.registerMessages({ en: { notes: { errors: { notFound: 'Note {noteId} not found' }, validation: { title: { required: 'Title is required' } }, }, }, fr: { notes: { errors: { notFound: 'Note {noteId} introuvable' } }, }, }), ],})export class AppModule {}forRoot() accepts locale configuration only, never messages. Keeping the two separate is what lets a module register its translations without owning the root locale config.
I18nModuleOptions
Section titled “I18nModuleOptions”| Option | Type | Default | Description |
|---|---|---|---|
defaultLocale | string | 'en' | Locale used when detection finds nothing usable |
fallbackLocale | string | 'en' | Locale used when a key is missing in the requested locale |
locales | string[] | ['en'] | Supported locales. Requests for any locale outside this list fall back to defaultLocale |
detection | LanguageDetectionOptions | { strategy: 'cookie' } | How the request locale is resolved |
Per-module keyed namespaces
Section titled “Per-module keyed namespaces”Messages live in a keyed registry. Each module owns exactly one distinct top-level namespace (for example notes, billing, uploads) and nests its keys underneath it. Any module can call registerMessages(), and contributions are deep-merged across every registration. Later registrations override earlier ones at the leaf level.
import { Module } from 'stratal/module'import { I18nModule } from 'stratal/i18n'import { billingMessages } from './i18n/en'
@Module({ imports: [ I18nModule.registerMessages({ en: { billing: billingMessages.en }, fr: { billing: { errors: { subscriptionNotFound: 'Abonnement introuvable' } } }, }), ],})export class BillingModule {}Access keys with flat dot-notation: i18n.t('billing.errors.subscriptionNotFound').
Reserved namespaces
Section titled “Reserved namespaces”The top-level namespaces common, emails, validation, and zodI18n are owned by core. You may register additional locale translations for these keys, but you cannot augment their type shapes. Choose a different top-level namespace for your modules.
Defining messages
Section titled “Defining messages”Messages are nested objects. Each leaf path becomes a dot-notation translation key. Colocate them with the module, commonly with the type augmentation in the same file.
export const billingMessages = { en: { errors: { subscriptionNotFound: 'Subscription not found' }, invoices: { issued: 'Invoice {number} issued' }, },} as const
declare module 'stratal/i18n' { interface AppMessageNamespaces { billing: typeof billingMessages['en'] }}export const billingMessagesFr = { fr: { errors: { subscriptionNotFound: 'Abonnement introuvable' }, invoices: { issued: 'Facture {number} émise' }, },} as constAugmenting AppMessageNamespaces is what makes i18n.t('billing.invoices.issued') fully type-checked.
ICU MessageFormat
Section titled “ICU MessageFormat”Messages are compiled with intl-messageformat, so they support the full ICU MessageFormat syntax. Simple {name} placeholders interpolate the matching parameter:
// Message: 'Hello, {name}!'i18n.t('common.greeting', { name: 'Alice' })// => 'Hello, Alice!'Use ICU plural and select arguments for grammatically correct output:
// Message:// '{count, plural, =0 {No notifications} one {# notification} other {# notifications}}'i18n.t('common.notifications', { count: 0 }) // => 'No notifications'i18n.t('common.notifications', { count: 1 }) // => '1 notification'i18n.t('common.notifications', { count: 5 }) // => '5 notifications'
// Message:// '{gender, select, female {She replied} male {He replied} other {They replied}}'i18n.t('common.reply', { gender: 'female' }) // => 'She replied'Inside a plural or selectordinal block, # renders the count formatted for the active locale. The locale also drives number, date, and time formats:
// Message: 'Total: {amount, number, ::currency/USD}'// Message: 'Created {when, date, medium}'Using translations
Section titled “Using translations”Inject I18nService and call t() with a key:
import { Transient, inject } from 'stratal/di'import { I18N_TOKENS, type II18nService } from 'stratal/i18n'
@Transient()export class BillingService { constructor( @inject(I18N_TOKENS.I18nService) private readonly i18n: II18nService, ) {}
getInvoiceMessage(number: string) { return this.i18n.t('billing.invoices.issued', { number }) }}The t method signature:
t(key: MessageKeys, params?: MessageParams): stringkeyis a type-safe union of every system and app message key (dot-notation paths).paramsis an optionalRecord<string, string | number>for ICU interpolation.- Returns the translated string, or the key itself when no translation is found.
withI18n() outside services
Section titled “withI18n() outside services”withI18n() translates a key anywhere in request-scoped code: services, middleware, error handlers, cron jobs, or queue consumers. It resolves I18nService from the container for you.
import { withI18n } from 'stratal/i18n'
const message = withI18n('errors.notFound')const greeting = withI18n('common.greeting', { name: 'Alice' })It uses the current request’s locale and returns the key itself when called outside a request context (for example during startup).
In error classes
Section titled “In error classes”Translate the message inside the error class so the response is localized to the request:
import { HttpException } from 'stratal/errors'import { withI18n } from 'stratal/i18n'
export class NoteNotFoundError extends HttpException { constructor(noteId: string) { super(404, withI18n('notes.errors.notFound', { noteId })) }}Locale detection
Section titled “Locale detection”Detection middleware runs automatically on every route. The detection strategy decides where the locale comes from.
| Strategy | Source | Example |
|---|---|---|
'cookie' (default) | locale cookie | Cookie: locale=fr |
'header' | Accept-Language header | Accept-Language: fr |
'querystring' | ?locale= query param | /api/users?locale=fr |
'path' | First URL path segment | /fr/api/users |
// Accept-Language header detectionI18nModule.forRoot({ defaultLocale: 'en', locales: ['en', 'fr'], detection: { strategy: 'header' },})
// Disable detection entirelyI18nModule.forRoot({ defaultLocale: 'en', locales: ['en', 'fr'], detection: { enabled: false },})If the detected locale is not in locales, the request falls back to defaultLocale. At runtime you can read or override the locale through the RouterContext:
ctx.getLocale() // current locale for the requestctx.setLocale('fr') // override it for the rest of the requestPath-based detection
Section titled “Path-based detection”When strategy: 'path', every route is registered with a /{locale} prefix (for example /{locale}/api/users). The locale parameter is injected into each route’s params schema, validated against locales, and documented in OpenAPI as a path parameter.
The prefixDefaultLocale option controls whether the default locale gets a URL prefix:
| Value | Behavior |
|---|---|
false (default) | Default locale is unprefixed (/users); others are prefixed (/fr/users). /en/users returns 404. |
'redirect' | Same as false, but /en/users is 301-redirected to /users. |
true | Every locale is prefixed (/en/users, /fr/users); /users returns 404. |
I18nModule.forRoot({ defaultLocale: 'en', locales: ['en', 'fr'], detection: { strategy: 'path', prefixDefaultLocale: 'redirect' },})Validation error translation
Section titled “Validation error translation”When the i18n module is active, Zod validation errors are translated automatically based on the request locale. The module installs a global Zod error map that intercepts every validation issue and resolves it through the i18n service.
A request with the French locale that fails body validation returns French issue messages in the standard error response. The translation covers every Zod v4 issue type, including invalid_type, too_small, too_big, invalid_format, and string formats like email, url, and uuid.
Custom validation messages with withZodI18n
Section titled “Custom validation messages with withZodI18n”Use withZodI18n() from stratal/validation to attach a translatable message key to a Zod validator. It returns a Zod error config that resolves the key at validation time using the request’s locale.
import { z, withZodI18n } from 'stratal/validation'
export const createNoteSchema = z.object({ title: z.string() .min(1, withZodI18n('notes.validation.title.required')) .max(255, withZodI18n('notes.validation.title.max', { max: 255 })), content: z.string().optional(),})withZodI18n(key, params?) works with both built-in validators (.min, .max, .email, …) and .refine(). Pass ICU parameters as the second argument.
Built-in system messages
Section titled “Built-in system messages”Stratal ships English translations under four reserved namespaces:
common.*: shared framework strings (API metadata, security scheme descriptions)emails.*: built-in email template stringsvalidation.*: form validation messages (validation.required,validation.email,validation.minLength, …)zodI18n.*: translations for every Zod v4 issue code (zodI18n.errors.required,zodI18n.errors.invalid_type,zodI18n.errors.too_small.string.inclusive, …)
To override any system message, register the same key in your application messages. Your value takes precedence after the deep merge.
Type-safe message keys
Section titled “Type-safe message keys”MessageKeys is derived from two sources:
-
System keys: inferred from core’s built-in
common.*,emails.*,validation.*, andzodI18n.*messages. -
App keys: derived from
AppMessageNamespaces, the keyed registry each module augments with its own distinct namespace.
export const userMessages = { en: { welcome: 'Welcome, {name}!', errors: { notFound: 'User {userId} not found' }, },} as const
declare module 'stratal/i18n' { interface AppMessageNamespaces { users: typeof userMessages['en'] }}Once augmented, both system and app keys are autocompleted and compile-checked:
this.i18n.t('validation.required') // system keythis.i18n.t('users.welcome', { name: 'Alice' }) // app keythis.i18n.t('users.errors.notFound', { userId: 'u_123' }) // app keyDI tokens
Section titled “DI tokens”All tokens are exported from stratal/i18n via I18N_TOKENS:
| Token | Service | Scope |
|---|---|---|
I18N_TOKENS.MessageRegistry | MessageRegistry | Singleton |
I18N_TOKENS.MessageLoader | MessageLoaderService | Singleton |
I18N_TOKENS.I18nService | I18nService | Request |
I18N_TOKENS.Options | I18nModuleOptions | Value |
CLI introspection
Section titled “CLI introspection”The Quarry CLI ships i18n commands for auditing translations:
npx quarry i18n:check # Audit missing/extra keys (exit code 1 when issues found)npx quarry i18n:stats # Coverage percentage per localenpx quarry i18n:list # List keys with per-locale coveragenpx quarry i18n:search # Search keys or values by substringnpx quarry i18n:namespaces # Key counts by namespacenpx quarry i18n:duplicates # Find keys with duplicate translation valuesi18n:check exits with code 1 when it finds issues, which makes it a good CI guard against untranslated keys.
Next steps
Section titled “Next steps”- Validation to learn how validation schemas integrate with i18n error messages.
- Error Handling for how translated errors are returned to clients.
- Queues to see how locale context is preserved across queue messages.