i18n-typed-store-nest
v0.5.0
Published
NestJS module for i18n-typed-store — request-scoped type-safe translations with locale detection
Downloads
213
Maintainers
Readme
i18n-typed-store-nest
⚠️ WARNING: The library API is under active development and may change significantly between versions. Use exact versions in package.json and carefully read the changelog when updating.
Type-safe translation store for NestJS with full TypeScript support. Integration of i18n-typed-store for NestJS applications with automatic locale detection from requests, decorators for convenient access to translations, and preload support.
Features
- ✅ Full TypeScript support - Complete type safety for translations and locales
- ✅ IDE integration - Go-to definition, autocomplete with translation classes
- ✅ Automatic locale detection - From query parameters, cookies, headers, route parameters
- ✅ Decorators - Convenient decorators
@I18n(),@Locale(),@Translation()for accessing translations - ✅ Global Interceptor - Automatic interceptor registration for locale detection
- ✅ Middleware support - Alternative to interceptor via middleware
- ✅ Translation preloading - Automatic preloading on module initialization
- ✅ Type-safe API - Compile-time validation of translation keys and locales
- ✅ Lazy loading - Load translations only when needed
- ✅ Fallback locales - Automatic merging with fallback translations
Installation
npm install i18n-typed-store-nestyarn add i18n-typed-store-nestpnpm add i18n-typed-store-nestQuick Start
1. Creating translation store
First, create a translation store (shared across the project):
// i18n/store.ts
import { createTranslationStore } from 'i18n-typed-store';
import type CommonTranslationsEn from './translations/common/en';
import type ErrorsTranslationsEn from './translations/errors/en';
const namespaces = { common: 'common', errors: 'errors' } as const;
const locales = { en: 'en', ru: 'ru' } as const;
export interface ITranslationStoreTypes extends Record<keyof typeof namespaces, any> {
common: CommonTranslationsEn;
errors: ErrorsTranslationsEn;
}
export const store = createTranslationStore({
namespaces,
locales,
loadModule: async (locale, namespace) => {
return await import(`./translations/${namespace}/${locale}.ts`);
},
extractTranslation: (module) => new module.default(),
defaultLocale: 'en',
useFallback: true,
fallbackLocale: 'en',
}).type<ITranslationStoreTypes>();2. Module configuration
// app.module.ts
import { Module } from '@nestjs/common';
import { I18nModule } from 'i18n-typed-store-nest';
import { store } from './i18n/store';
@Module({
imports: [
I18nModule.forRoot({
store,
defaultLocale: 'en',
availableLocales: ['en', 'ru'],
headerName: 'accept-language',
queryParamName: 'locale',
cookieName: 'locale',
parseAcceptLanguage: true,
// Preload all translations on initialization
preload: true,
}),
],
})
export class AppModule {}
// I18nInterceptor is automatically registered and will detect locale from each request3. Using decorators
The module automatically detects locale from the request (query parameters, cookies, headers) and sets it in the service. You can use decorators to access translations:
// app.controller.ts
import { Controller, Get } from '@nestjs/common';
import { I18n, Locale, Translation } from 'i18n-typed-store-nest';
import type CommonTranslationsEn from './translations/common/en';
@Controller()
export class AppController {
@Get()
async getData(@I18n() i18n: I18nService, @Locale() locale: string, @Translation('common') translation: CommonTranslationsEn) {
// Use I18nService directly
const currentLocale = i18n.getLocale();
// Load translation if needed
await i18n.loadTranslation('errors');
const errorTranslation = i18n.getCurrentTranslation('errors');
// Use translation from decorator
return {
locale,
greeting: translation.greeting,
title: translation.title,
errorMessage: errorTranslation?.notFound,
};
}
}4. Using getTranslationByKey method
To get translations by string keys, use the getTranslationByKey method:
// app.controller.ts
import { Controller, Get } from '@nestjs/common';
import { I18n } from 'i18n-typed-store-nest';
@Controller()
export class AppController {
@Get()
async getData(@I18n() i18n: I18nService) {
// Get entire namespace object
const common = i18n.getTranslationByKey('common');
// Returns: { greeting: string, title: string, ... }
// Get specific value
const greeting = i18n.getTranslationByKey('common.greeting');
// Returns: string ("Hello")
// Get nested value
const saveButton = i18n.getTranslationByKey('common.buttons.save');
// Returns: string ("Save")
// Get with specified locale
const greetingRu = i18n.getTranslationByKey('common.greeting', 'ru');
// Returns: string ("Привет")
return {
greeting,
saveButton,
};
}
}Module Configuration
I18nModule.forRoot
The module is configured via I18nModule.forRoot():
I18nModule.forRoot<N, L, M>(options: I18nModuleOptions<N, L, M>): DynamicModuleOptions:
store- Translation store instance (required)defaultLocale- Default locale (required). Must be a key ofstore.locales— validated at configuration time with a descriptive erroravailableLocales- Array of available locales for validation (optional)headerName- Header name for extracting locale (default:'accept-language')queryParamName- Query parameter name for extracting locale (default:'locale')cookieName- Cookie name for extracting locale (default:'locale')parseAcceptLanguage- Whether to parse Accept-Language header (default:true)resolvers- Locale detection sources applied in order (default:['query', 'route', 'cookie', 'header']). Entries are the built-in source names or custom functions(request) => string | undefined. Every detected value is matched against the store locales with BCP 47 rules — case-insensitive, with region/script fallbacks (?locale=ru-RUresolves to'ru',Accept-Language: EN-usmatches a'en-US'key)useGlobalInterceptor- Whether to registerI18nInterceptorglobally viaAPP_INTERCEPTOR(default:true). Whenfalse, the exportedI18nInterceptorcan be wired manually (e.g.@UseInterceptors(I18nInterceptor)). ForforRootAsyncset this flag on the async options object itselfpreload- Translation preload configuration:true- preload all namespaces and locales- Object with settings:
namespaces- Array of namespaces to preload (if not specified, all are loaded)locales- Array of locales to preload (if not specified, all are loaded)fromCache- Whether to use cache when preloading (default:true)
- If not specified, preloading is not performed
Examples:
// Preload all translations
I18nModule.forRoot({
store,
defaultLocale: 'en',
preload: true,
});
// Preload specific namespaces
I18nModule.forRoot({
store,
defaultLocale: 'en',
preload: {
namespaces: ['common', 'errors'],
locales: ['en', 'ru'],
},
});
// Without preloading
I18nModule.forRoot({
store,
defaultLocale: 'en',
// preload not specified
});Using Decorators
@I18n()
Gets the I18nService instance:
@Get()
async getData(@I18n() i18n: I18nService) {
const translation = i18n.getCurrentTranslation('common');
return translation?.greeting;
}@Locale()
Gets the current locale as a string:
@Get()
async getData(@Locale() locale: string) {
return { locale };
}@I18nLang() is exported as an alias of @Locale() for developers migrating from nestjs-i18n.
@Translation(namespace)
Gets the translation for the specified namespace. Translation is loaded automatically if not yet loaded:
@Get()
async getData(@Translation('common') translation: CommonTranslationsEn | undefined) {
return translation?.greeting;
}Important: The @Translation() decorator automatically loads the translation if it is not yet loaded. This happens asynchronously, so the method must be async. If the translation fails to load, the parameter is undefined — the route is not failed with a 500.
I18nService API
Service for working with translations and locales. Can be injected directly into controllers and services:
@Injectable()
export class AppService {
constructor(
@Inject(I18N_SERVICE)
private readonly i18nService: I18nService,
) {}
}Or use the I18N_SERVICE token:
import { I18N_SERVICE, I18nService } from 'i18n-typed-store-nest';
@Injectable()
export class AppService {
constructor(
@Inject(I18N_SERVICE)
private readonly i18nService: I18nService,
) {}
}Methods
setLocale(locale: keyof L | string): void
Sets the active locale, scoped to where the call happens:
- Inside a request context (bound by
I18nInterceptor/I18nMiddleware): changes only this request's locale — identical tosetRequestLocale(). Parallel requests are unaffected. - Outside a request context (bootstrap, CLI, workers): changes the store-wide default locale.
Accepts an exact store key or a BCP 47 tag that resolves to one ('ru-RU' → 'ru'); throws for unknown locales.
this.i18nService.setLocale('ru');getLocale(): keyof L
Returns the current locale.
const locale = this.i18nService.getLocale(); // 'en'getLocales(): L
Returns an object with available locales.
const locales = this.i18nService.getLocales(); // { en: 'en', ru: 'ru' }loadTranslation(namespace: K, locale?: keyof L, fromCache?: boolean): Promise<void>
Loads translation for the specified namespace.
await this.i18nService.loadTranslation('common', 'en');
await this.i18nService.loadTranslation('common'); // uses current localegetTranslation(namespace: K, locale?: keyof L): Promise<M[K] | undefined>
Gets translation for the specified namespace. Automatically loads translation if not yet loaded. Resolves to undefined when the load fails (the error state remains observable on the store).
const translation = await this.i18nService.getTranslation('common', 'en');
const translation = await this.i18nService.getTranslation('common'); // uses current localegetCurrentTranslation(namespace: K): M[K] | undefined
Gets the already-loaded translation for the specified namespace and the current locale — the per-request locale inside a request, the store default outside of one. Does not trigger loading. Safe under concurrent traffic: each request reads its own locale's cache slot.
const translation = this.i18nService.getCurrentTranslation('common');
// Returns undefined if translation is not loaded for the current localegetTranslationByKey(key: Key, locale?: keyof L): GetTranslationValue<M, Key>
Gets translation value by key. Supports string keys in the format "namespace", "namespace.key" or "namespace.nested.key".
// Get entire namespace
const common = this.i18nService.getTranslationByKey('common');
// Get specific value
const greeting = this.i18nService.getTranslationByKey('common.greeting');
// Get nested value
const saveButton = this.i18nService.getTranslationByKey('common.buttons.save');
// With locale specified
const greetingRu = this.i18nService.getTranslationByKey('common.greeting', 'ru');getStore(): TranslationStore<N, L, M>
Returns the translation store instance for direct access to store API.
const store = this.i18nService.getStore();
store.changeLocale('ru');I18nInterceptor
Global interceptor that automatically detects locale from the request and sets it in I18nService. Registered automatically when using I18nModule.forRoot().
Automatic Registration (Recommended)
Interceptor is automatically registered as a global interceptor when using I18nModule.forRoot(). No additional configuration needed:
// app.module.ts
import { Module } from '@nestjs/common';
import { I18nModule } from 'i18n-typed-store-nest';
import { store } from './i18n/store';
@Module({
imports: [
I18nModule.forRoot({
store,
defaultLocale: 'en',
availableLocales: ['en', 'ru'],
}),
],
})
export class AppModule {}
// I18nInterceptor is automatically registered and will work for all requestsController/Method Level Usage
You can also apply the interceptor to specific controllers or methods:
// app.controller.ts
import { Controller, Get, UseInterceptors } from '@nestjs/common';
import { I18nInterceptor } from 'i18n-typed-store-nest';
@Controller()
@UseInterceptors(I18nInterceptor) // Apply to all methods in this controller
export class AppController {
@Get()
@UseInterceptors(I18nInterceptor) // Or apply to specific method
async getData() {
// Locale is automatically detected and set
return { message: 'Hello' };
}
}How It Works
- Intercepts all incoming HTTP requests
- Extracts locale from request (query parameters, cookies, headers, route parameters)
- Binds the resolved locale to the request via
AsyncLocalStorage(per-request, not on the sharedI18nServicesingleton) so concurrent requests never overwrite each other's locale - Attaches
I18nServiceto the request object for use in parameter decorators - Continues with request processing
Using Middleware (Alternative to Interceptor)
If you prefer using middleware instead of a global interceptor:
// app.module.ts
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { I18nModule, I18nMiddleware } from 'i18n-typed-store-nest';
import { store } from './i18n/store';
@Module({
imports: [
I18nModule.forRoot({
store,
defaultLocale: 'en',
availableLocales: ['en', 'ru'],
}),
],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(I18nMiddleware).forRoutes('*');
}
}Note: When using middleware, the interceptor is still automatically registered. You can disable it by removing it from providers in I18nModule.forRoot(), but this requires module modification. In most cases, using the interceptor is sufficient.
Locale Detection
The module automatically detects locale from the request in the following priority order:
- Query parameter (e.g.,
?locale=en) - Route parameter (e.g.,
/api/:locale/users) - Cookie (e.g.,
locale=en) - Header
Accept-Language(parsed automatically) - Default locale (from configuration)
Example request:
GET /api/data?locale=ru
Cookie: locale=en
Header: Accept-Language: ru-RU,ru;q=0.9,en-US;q=0.8Result: locale will be 'ru' (query parameter has highest priority).
Accept-Language Parsing
When parseAcceptLanguage: true, the module parses the Accept-Language header according to RFC 2616 standard:
Accept-Language: ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7The module:
- Parses languages by priority (q-value)
- Searches for exact match with available locales
- Searches for base language match (e.g.,
rufromru-RU) - Uses default locale if nothing is found
Type-Safe Translations
All translations are fully type-safe:
@Controller()
export class AppController {
@Get()
async getData(@Translation('common') translation: CommonTranslationsEn) {
// ✅ TypeScript knows all translation keys
const greeting = translation.greeting;
const title = translation.title;
// ❌ TypeScript error: Property 'invalidKey' does not exist
// const invalid = translation.invalidKey;
return { greeting, title };
}
}Translation Class Structure
The library is designed to work with TypeScript classes for translations, providing full type safety and IDE support (go-to definition, autocomplete). Example translation class:
// translations/common/en.ts
import { createPluralSelector } from 'i18n-typed-store';
const plur = createPluralSelector('en');
export default class CommonTranslationsEn {
title = 'Welcome';
loading = 'Loading...';
error = 'An error occurred';
buttons = {
save: 'Save',
cancel: 'Cancel',
delete: 'Delete',
};
messages = {
notFound: 'Not found',
unauthorized: 'You are not authorized to perform this action',
};
// Pluralization method
items = (count: number) =>
count +
' ' +
plur(count, {
one: 'item',
other: 'items',
});
}Benefits of using classes:
- ✅ Full TypeScript type safety with IDE go-to definition support
- ✅ Methods for pluralization and dynamic translations
- ✅ Better code organization and maintainability
- ✅ Compile-time validation of translation keys
Examples
Complete Controller Example
// app.controller.ts
import { Controller, Get, Post, Body, Param } from '@nestjs/common';
import { I18n, Locale, Translation } from 'i18n-typed-store-nest';
import type CommonTranslationsEn from './translations/common/en';
import type ErrorsTranslationsEn from './translations/errors/en';
@Controller('api')
export class AppController {
@Get('greeting')
async getGreeting(@Locale() locale: string, @Translation('common') translation: CommonTranslationsEn) {
return {
locale,
message: translation.greeting,
title: translation.title,
};
}
@Get('errors/:code')
async getError(@Param('code') code: string, @I18n() i18n: I18nService) {
await i18n.loadTranslation('errors');
const errors = i18n.getCurrentTranslation('errors');
return {
error: errors?.[code] || 'Unknown error',
};
}
@Post('change-locale')
async changeLocale(@Body('locale') locale: 'en' | 'ru', @I18n() i18n: I18nService) {
i18n.setLocale(locale);
return { success: true, locale: i18n.getLocale() };
}
}Service Example
// app.service.ts
import { Injectable, Inject } from '@nestjs/common';
import { I18N_SERVICE, I18nService } from 'i18n-typed-store-nest';
@Injectable()
export class AppService {
constructor(
@Inject(I18N_SERVICE)
private readonly i18nService: I18nService,
) {}
async getGreeting() {
// Load translation
await this.i18nService.loadTranslation('common');
// Get translation
const translation = this.i18nService.getCurrentTranslation('common');
return translation?.greeting || 'Hello';
}
async changeLocale(locale: 'en' | 'ru') {
this.i18nService.setLocale(locale);
}
async getLocalizedMessage(key: string) {
// Use getTranslationByKey for string keys
return this.i18nService.getTranslationByKey(`common.${key}`);
}
}Pluralization Example
// translations/products/en.ts
import { createPluralSelector } from 'i18n-typed-store';
const plur = createPluralSelector('en');
export default class ProductsTranslationsEn {
title = 'Products';
addToCart = 'Add to Cart';
// Pluralization method
productCount = (count: number) =>
count +
' ' +
plur(count, {
one: 'product',
other: 'products',
});
itemsInCart = (count: number) =>
count +
' ' +
plur(count, {
zero: 'No items',
one: 'item',
other: 'items',
}) +
' in cart';
}// products.controller.ts
@Controller('products')
export class ProductsController {
@Get('count')
async getCount(@Query('count') count: number, @Translation('products') translation: ProductsTranslationsEn) {
return {
message: translation.productCount(count),
cartMessage: translation.itemsInCart(count),
};
}
}Translation Preloading
The module supports translation preloading on initialization. This is useful for preloading frequently used translations.
Preload All Translations
I18nModule.forRoot({
store,
defaultLocale: 'en',
preload: true, // Preload all namespaces and locales
});Preload Specific Namespaces
I18nModule.forRoot({
store,
defaultLocale: 'en',
preload: {
namespaces: ['common', 'errors'],
locales: ['en', 'ru'],
},
});Preload Specific Locales
I18nModule.forRoot({
store,
defaultLocale: 'en',
preload: {
locales: ['en'], // English only
},
});Without Preloading
I18nModule.forRoot({
store,
defaultLocale: 'en',
// preload not specified - translations are loaded on demand
});Advanced Scenarios
Working with Multiple Namespaces
@Controller()
export class AppController {
@Get()
async getData(@I18n() i18n: I18nService) {
// Load multiple translations
await Promise.all([i18n.loadTranslation('common'), i18n.loadTranslation('errors'), i18n.loadTranslation('ui')]);
// Use translations
const common = i18n.getCurrentTranslation('common');
const errors = i18n.getCurrentTranslation('errors');
const ui = i18n.getCurrentTranslation('ui');
return {
greeting: common?.greeting,
notFound: errors?.notFound,
saveButton: ui?.buttons?.save,
};
}
}Using getTranslationByKey for Dynamic Keys
@Controller()
export class AppController {
@Get('messages/:key')
async getMessage(@Param('key') key: string, @I18n() i18n: I18nService) {
// Use dynamic keys (with loss of type safety)
const message = i18n.getTranslationByKey(`common.${key}` as any);
return { message };
}
}Direct Store Access
@Controller()
export class AppController {
@Get()
async getData(@I18n() i18n: I18nService) {
const store = i18n.getStore();
// Direct access to store API
store.changeLocale('ru');
await store.translations.common.load('ru');
return store.translations.common.currentTranslation;
}
}API Reference
I18nModule
Global NestJS module for internationalization.
I18nModule.forRoot<N, L, M>(options: I18nModuleOptions<N, L, M>): DynamicModuleI18nService
Service for working with translations and locales.
class I18nService<N, L, M> {
setLocale(locale: keyof L | string): void;
setRequestLocale(locale: keyof L | string | undefined): void;
getLocale(): keyof L;
getLocales(): L;
loadTranslation<K extends keyof N>(namespace: K, locale?: keyof L, fromCache?: boolean): Promise<void>;
getTranslation<K extends keyof N>(namespace: K, locale?: keyof L): Promise<M[K] | undefined>;
getCurrentTranslation<K extends keyof N>(namespace: K): M[K] | undefined;
getTranslationByKey<Key extends TranslationKeys<M>>(key: Key, locale?: keyof L): GetTranslationValue<M, Key>;
getStore(): TranslationStore<N, L, M>;
}I18nInterceptor
Global interceptor that automatically detects the request locale and binds it to the request scope. Registered automatically when using I18nModule.forRoot() unless useGlobalInterceptor: false is set. Works on HTTP and GraphQL contexts (the request is read from the GraphQL resolver context without depending on @nestjs/graphql); on WS/RPC contexts it binds the default locale instead of crashing.
I18nMiddleware
Alternative to interceptor for setting locale from request. Can be used manually:
consumer.apply(I18nMiddleware).forRoutes('*');Decorators
@I18n()
Gets the I18nService instance.
@Get()
async getData(@I18n() i18n: I18nService) {
const translation = i18n.getTranslation('common');
return translation?.greeting;
}@Locale()
Gets the current locale as a string.
@Get()
async getData(@Locale() locale: string) {
return { locale };
}@Translation(namespace)
Gets the translation for the specified namespace.
@Get()
async getData(@Translation('common') translation: CommonTranslationsEn) {
return translation.greeting;
}Dependency Injection Tokens
The library exports tokens for use in dependency injection:
I18N_STORE- Token for translation storeI18N_OPTIONS- Token for module optionsI18N_SERVICE- Token for I18nService (recommended to use this token for service injection)
License
MIT
Author
Alexander Lvov
Repository
Related Packages
- i18n-typed-store - Core library
- i18n-typed-store-react - React integration
