i18n Package
The @hazeljs/i18n package provides full internationalization support for HazelJS applications — locale detection, JSON-based translations, variable interpolation, pluralization, and native Intl formatting — with zero external dependencies.
Purpose
Building multilingual applications requires consistent locale detection across every request, reliable key-based translation lookup with fallbacks, plural-form selection across different languages, and formatted numbers, dates, and currencies that respect locale conventions. The @hazeljs/i18n package solves all of these with a decorator-friendly API that fits naturally into the HazelJS module system:
- Zero dependencies — uses only Node.js built-ins (
fs/promises) and the nativeIntlAPI - Decorator-based locale injection —
@Lang()injects the detected locale directly into controller parameters - Dot-notation key lookup — nested JSON keys accessed as
"errors.notFound"with transparent fallback - Pluralization via
Intl.PluralRules— correct plural forms for any language automatically - Full Intl formatting — numbers, dates, currencies, and relative time via native browser/Node APIs
- Configurable detection — query param → cookie →
Accept-Languageheader, in any order you choose
Architecture
flowchart TD Request["Incoming Request"] --> LocaleMiddleware["LocaleMiddleware\n(query / cookie / Accept-Language)"] LocaleMiddleware --> RequestObject["Request Object\n(__hazel_locale__)"] RequestObject --> Controller["@Controller method"] Controller --> LangDecorator["@Lang() param\nextracts locale"] Controller --> I18nService["I18nService.t(key, opts)"] I18nService --> TranslationStore["In-memory LocaleStore\n(Map locale → JSON)"] TranslationStore --> JSONFiles["translations/\nen.json · fr.json · ar.json"] I18nService --> IntlAPI["Native Intl API\n(numbers · dates · currency)"]
Key Components
I18nModule— registers the module withforRoot()orforRootAsync(); loads JSON files at startupI18nService— injectable service witht(),has(),getLocales()and aformat.*namespaceLocaleMiddleware— detects locale per-request from query string, cookie, orAccept-Languageheader@Lang()— parameter decorator that injects the request-scoped locale string into controller methodsI18nInterceptor— optional interceptor that auto-translates amessagefield in response objectsTranslationLoader— reads all<locale>.jsonfiles from the configured directory on startup
Installation
npm install @hazeljs/i18n
Quick Start
1. Create translation files
my-app/
└── translations/
├── en.json
└── fr.json
{
"welcome": "Welcome, {name}!",
"goodbye": "Goodbye!",
"items": {
"one": "1 item",
"other": "{count} items"
},
"errors": {
"notFound": "Resource not found.",
"unauthorized": "You are not authorized."
}
}
2. Register the module
import { HazelModule } from '@hazeljs/core';
import { I18nModule } from '@hazeljs/i18n';
import { AppController } from './app.controller';
@HazelModule({
imports: [
I18nModule.forRoot({
defaultLocale: 'en',
fallbackLocale: 'en',
translationsPath: './translations',
detection: ['query', 'cookie', 'header'],
}),
],
controllers: [AppController],
})
export class AppModule {}
3. Apply the locale middleware
import { HazelApp } from '@hazeljs/core';
import { LocaleMiddleware } from '@hazeljs/i18n';
const app = new HazelApp(AppModule);
const localeMw = app.get(LocaleMiddleware);
app.use((req, res, next) => localeMw.handle(req, res, next));
await app.listen(3000);
4. Translate in a controller
import { Controller, Get } from '@hazeljs/core';
import { I18nService, Lang } from '@hazeljs/i18n';
@Controller('/greet')
export class AppController {
constructor(private readonly i18n: I18nService) {}
@Get('/')
hello(@Lang() locale: string) {
return {
message: this.i18n.t('welcome', { locale, vars: { name: 'Alice' } }),
};
// GET /greet?lang=fr → { message: "Bienvenue, Alice !" }
// GET /greet → { message: "Welcome, Alice!" }
}
}
Module Configuration
I18nModule.forRoot(options)
| Option | Type | Default | Description |
|---|---|---|---|
defaultLocale | string | 'en' | Locale used when none is detected |
fallbackLocale | string | same as default | Locale used when a key is missing |
translationsPath | string | './translations' | Path to the directory of <locale>.json files |
detection | LocaleDetectionStrategy[] | ['query','cookie','header'] | Ordered detection strategies |
queryParam | string | 'lang' | Query-string parameter name |
cookieName | string | 'locale' | Cookie name |
isGlobal | boolean | true | Register as a global module |
I18nModule.forRootAsync(options)
Use when module options must come from the container at runtime (e.g. from ConfigService):
I18nModule.forRootAsync({
useFactory: async (config: ConfigService) => ({
defaultLocale: config.get('LOCALE', 'en'),
translationsPath: config.get('TRANSLATIONS_PATH', './translations'),
}),
inject: [ConfigService],
})
I18nService
t(key, opts?) — translate a key
Resolves dot-notation keys, applies {placeholder} interpolation, and selects the correct plural form:
// Simple key
i18n.t('goodbye')
// → "Goodbye!"
// Nested key
i18n.t('errors.notFound')
// → "Resource not found."
// Interpolation
i18n.t('welcome', { vars: { name: 'Bob' } })
// → "Welcome, Bob!"
// Pluralization (uses Intl.PluralRules)
i18n.t('items', { count: 1, vars: { count: '1' } })
// → "1 item"
i18n.t('items', { count: 5, vars: { count: '5' } })
// → "5 items"
// Per-call locale override
i18n.t('welcome', { locale: 'fr', vars: { name: 'Bob' } })
// → "Bienvenue, Bob !"
format namespace
All formatters wrap the native Intl API and accept an optional locale override:
// Number formatting
i18n.format.number(1234567.89, 'de', { maximumFractionDigits: 2 })
// → "1.234.567,89"
// Date formatting
i18n.format.date(new Date(), 'fr', { dateStyle: 'long' })
// → "4 mars 2026"
// Currency formatting
i18n.format.currency(49.99, 'en', 'USD') // → "$49.99"
i18n.format.currency(49.99, 'de', 'EUR') // → "49,99 €"
// Relative time
i18n.format.relative(-3, 'day', 'en') // → "3 days ago"
i18n.format.relative(2, 'hour', 'fr') // → "dans 2 heures"
Utility methods
i18n.has('errors.notFound') // → true
i18n.getLocales() // → ['en', 'fr']
i18n.getKeys('fr') // → ['welcome', 'goodbye', 'items', ...]
Locale Detection
LocaleMiddleware runs each configured strategy in order and uses the first valid locale it finds, falling back to defaultLocale:
| Strategy | Source | Example |
|---|---|---|
'query' | URL query param | GET /api?lang=fr |
'cookie' | Cookie header | Cookie: locale=fr |
'header' | Accept-Language | Accept-Language: fr-FR,fr;q=0.9 |
The detected locale is stored on the request object under the __hazel_locale__ key and exposed via the Content-Language response header. Use getLocaleFromRequest(req) to read it outside of a controller.
@Lang() Decorator
Injects the request-scoped locale string as a controller method parameter. Returns undefined if LocaleMiddleware was not applied to the route.
@Get('/products')
list(@Lang() locale: string) {
const label = this.i18n.t('items', {
locale,
count: 10,
vars: { count: '10' },
});
return { label };
}
I18nInterceptor
When registered, the interceptor automatically translates a message field in the response object using the request locale. The message value is treated as an i18n key; if no matching translation exists the original value is preserved.
import { UseInterceptors } from '@hazeljs/core';
import { I18nInterceptor } from '@hazeljs/i18n';
@Controller('/users')
@UseInterceptors(I18nInterceptor)
export class UserController {
@Post('/')
create() {
return { message: 'user.created', data: { id: 1 } };
// → { message: 'User created successfully.', data: { id: 1 } }
}
}
Translation File Format
Files must be valid JSON named <locale>.json placed in the configured translationsPath directory. Keys can be nested to form namespaces. Leaf values are either plain strings or plural objects:
{
"flat_key": "A simple string.",
"interpolated": "Hello, {name}!",
"namespace": {
"nested_key": "Nested value."
},
"plural": {
"one": "One apple",
"other": "{count} apples",
"zero": "No apples"
}
}
Dot-notation resolves nested keys: i18n.t('namespace.nested_key').
Plural objects support all CLDR plural categories (zero, one, two, few, many, other) as determined by Intl.PluralRules for the active locale. The "other" form is used as the final fallback.
Complete Example
import { HazelModule, Controller, Get, Post, Service } from '@hazeljs/core';
import { I18nModule, I18nService, I18nInterceptor, Lang, LocaleMiddleware } from '@hazeljs/i18n';
// Service layer
@Service()
export class ProductService {
constructor(private readonly i18n: I18nService) {}
getProductLabel(count: number, locale: string) {
return this.i18n.t('products.count', {
locale,
count,
vars: { count: String(count) },
});
}
formatPrice(price: number, locale: string, currency: string) {
return this.i18n.format.currency(price, locale, currency);
}
}
// Controller
@Controller('/products')
export class ProductsController {
constructor(
private readonly productService: ProductService,
private readonly i18n: I18nService,
) {}
@Get('/')
list(@Lang() locale: string) {
const count = 42;
return {
label: this.productService.getProductLabel(count, locale),
price: this.productService.formatPrice(29.99, locale, 'USD'),
updatedAt: this.i18n.format.date(new Date(), locale, { dateStyle: 'medium' }),
};
// GET /products?lang=fr →
// { label: "42 produits", price: "29,99 $US", updatedAt: "4 mars 2026" }
}
}
// Module setup
@HazelModule({
imports: [
I18nModule.forRoot({
defaultLocale: 'en',
fallbackLocale: 'en',
translationsPath: './translations',
detection: ['query', 'cookie', 'header'],
}),
],
providers: [ProductService],
controllers: [ProductsController],
})
export class AppModule {}
Best Practices
-
Always apply
LocaleMiddlewareglobally — Register it on theHazelAppinstance before route handling so@Lang()always has a value. -
Use a consistent key naming convention — Prefer dot-namespaced keys like
users.createdorerrors.notFoundrather than flat keys to keep large translation files manageable. -
Keep plural objects complete — Always include an
"other"form as the fallback. Add"zero"for languages that need it (Arabic, Polish, etc.). -
Set
fallbackLocale— Even if you only have one translation file, setfallbackLocaleso missing keys degrade gracefully to the default language rather than returning the raw key. -
Use
forRootAsync()in production — LoadtranslationsPathanddefaultLocalefrom environment variables viaConfigServiceso you can configure them per environment without code changes. -
Quote
countin vars — Thevarsmap isRecord<string, string | number>, so you can pass numbers directly:vars: { count: 5 }.
What's Next?
- Learn about Middleware for registering
LocaleMiddlewareglobally - Read Config Package for async module configuration
- Explore Auth Package to combine i18n with localized error messages