i18n Package

npm downloads

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 native Intl API
  • 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-Language header, 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

  1. I18nModule — registers the module with forRoot() or forRootAsync(); loads JSON files at startup
  2. I18nService — injectable service with t(), has(), getLocales() and a format.* namespace
  3. LocaleMiddleware — detects locale per-request from query string, cookie, or Accept-Language header
  4. @Lang() — parameter decorator that injects the request-scoped locale string into controller methods
  5. I18nInterceptor — optional interceptor that auto-translates a message field in response objects
  6. TranslationLoader — reads all <locale>.json files 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)

OptionTypeDefaultDescription
defaultLocalestring'en'Locale used when none is detected
fallbackLocalestringsame as defaultLocale used when a key is missing
translationsPathstring'./translations'Path to the directory of <locale>.json files
detectionLocaleDetectionStrategy[]['query','cookie','header']Ordered detection strategies
queryParamstring'lang'Query-string parameter name
cookieNamestring'locale'Cookie name
isGlobalbooleantrueRegister 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:

StrategySourceExample
'query'URL query paramGET /api?lang=fr
'cookie'Cookie headerCookie: locale=fr
'header'Accept-LanguageAccept-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

  1. Always apply LocaleMiddleware globally — Register it on the HazelApp instance before route handling so @Lang() always has a value.

  2. Use a consistent key naming convention — Prefer dot-namespaced keys like users.created or errors.notFound rather than flat keys to keep large translation files manageable.

  3. Keep plural objects complete — Always include an "other" form as the fallback. Add "zero" for languages that need it (Arabic, Polish, etc.).

  4. Set fallbackLocale — Even if you only have one translation file, set fallbackLocale so missing keys degrade gracefully to the default language rather than returning the raw key.

  5. Use forRootAsync() in production — Load translationsPath and defaultLocale from environment variables via ConfigService so you can configure them per environment without code changes.

  6. Quote count in vars — The vars map is Record<string, string | number>, so you can pass numbers directly: vars: { count: 5 }.

What's Next?

  • Learn about Middleware for registering LocaleMiddleware globally
  • Read Config Package for async module configuration
  • Explore Auth Package to combine i18n with localized error messages