Skip to content

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.

OptionTypeDefaultDescription
defaultLocalestring'en'Locale used when detection finds nothing usable
fallbackLocalestring'en'Locale used when a key is missing in the requested locale
localesstring[]['en']Supported locales. Requests for any locale outside this list fall back to defaultLocale
detectionLanguageDetectionOptions{ strategy: 'cookie' }How the request locale is resolved

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.

src/modules/billing/billing.module.ts
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').

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.

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']
}
}

Augmenting AppMessageNamespaces is what makes i18n.t('billing.invoices.issued') fully type-checked.

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}'

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): string
  • key is a type-safe union of every system and app message key (dot-notation paths).
  • params is an optional Record<string, string | number> for ICU interpolation.
  • Returns the translated string, or the key itself when no translation is found.

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).

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 }))
}
}

Detection middleware runs automatically on every route. The detection strategy decides where the locale comes from.

StrategySourceExample
'cookie' (default)locale cookieCookie: locale=fr
'header'Accept-Language headerAccept-Language: fr
'querystring'?locale= query param/api/users?locale=fr
'path'First URL path segment/fr/api/users
// Accept-Language header detection
I18nModule.forRoot({
defaultLocale: 'en',
locales: ['en', 'fr'],
detection: { strategy: 'header' },
})
// Disable detection entirely
I18nModule.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 request
ctx.setLocale('fr') // override it for the rest of the request

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:

ValueBehavior
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.
trueEvery locale is prefixed (/en/users, /fr/users); /users returns 404.
I18nModule.forRoot({
defaultLocale: 'en',
locales: ['en', 'fr'],
detection: { strategy: 'path', prefixDefaultLocale: 'redirect' },
})

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.

Stratal ships English translations under four reserved namespaces:

  • common.*: shared framework strings (API metadata, security scheme descriptions)
  • emails.*: built-in email template strings
  • validation.*: 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.

MessageKeys is derived from two sources:

  1. System keys: inferred from core’s built-in common.*, emails.*, validation.*, and zodI18n.* messages.

  2. App keys: derived from AppMessageNamespaces, the keyed registry each module augments with its own distinct namespace.

src/modules/users/i18n/en.ts
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 key
this.i18n.t('users.welcome', { name: 'Alice' }) // app key
this.i18n.t('users.errors.notFound', { userId: 'u_123' }) // app key

All tokens are exported from stratal/i18n via I18N_TOKENS:

TokenServiceScope
I18N_TOKENS.MessageRegistryMessageRegistrySingleton
I18N_TOKENS.MessageLoaderMessageLoaderServiceSingleton
I18N_TOKENS.I18nServiceI18nServiceRequest
I18N_TOKENS.OptionsI18nModuleOptionsValue

The Quarry CLI ships i18n commands for auditing translations:

Terminal window
npx quarry i18n:check # Audit missing/extra keys (exit code 1 when issues found)
npx quarry i18n:stats # Coverage percentage per locale
npx quarry i18n:list # List keys with per-locale coverage
npx quarry i18n:search # Search keys or values by substring
npx quarry i18n:namespaces # Key counts by namespace
npx quarry i18n:duplicates # Find keys with duplicate translation values

i18n:check exits with code 1 when it finds issues, which makes it a good CI guard against untranslated keys.

  • 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.