JSON-based model translations for NestJS + TypeORM with locale-aware getters, fallback resolution, and query helpers.
This package provides automatic locale-aware API responses for NestJS + TypeORM. Translations are stored as JSON objects in your database columns — no separate translations table needed.
Send an Accept-Language header and your API returns resolved strings automatically:
GET /products/1
Accept-Language: ar
→ { "name": "حاسوب محمول", "slug": "laptop" }
No header? You get the full translation map:
GET /products/1
→ { "name": { "en": "Laptop", "ar": "حاسوب محمول" }, "slug": "laptop" }
- Installation
- Quick Start
- Automatic Locale Resolution (Middleware + Interceptor)
- Module Configuration
- Using the Mixin
- Translation Methods
- Translation Completeness
- Skip Translation (Admin Routes)
- GraphQL Support
- Using the Service Directly
- Validation
- Query Helpers
- Events
- Configuration Options
- Standalone Usage
- Testing
- Changelog
- Contributing
- Security
- Credits
- License
Install the package via npm:
npm install @nestbolt/translatableOr via yarn:
yarn add @nestbolt/translatableOr via pnpm:
pnpm add @nestbolt/translatableThis package requires the following peer dependencies, which you likely already have in a NestJS + TypeORM project:
@nestjs/common ^10.0.0 || ^11.0.0
@nestjs/core ^10.0.0 || ^11.0.0
class-validator ^0.14.0
class-transformer ^0.5.0
reflect-metadata ^0.1.13 || ^0.2.0
typeorm ^0.3.0
Optional:
- Install
@nestjs/event-emitterto enable translation change events - Install
@nestjs/graphqlto enable GraphQL resolver support
1. Register the module in your AppModule:
import { TranslatableModule } from "@nestbolt/translatable";
@Module({
imports: [
TypeOrmModule.forRoot({
/* ... */
}),
TranslatableModule.forRoot({
defaultLocale: "en",
fallbackLocales: ["en"],
}),
],
})
export class AppModule {}2. Create a translatable entity:
import { Entity, PrimaryGeneratedColumn, Column, BaseEntity } from "typeorm";
import { TranslatableMixin, Translatable } from "@nestbolt/translatable";
@Entity()
export class NewsItem extends TranslatableMixin(BaseEntity) {
@PrimaryGeneratedColumn()
id: number;
@Translatable()
@Column({ type: "jsonb", default: {} })
name: Record<string, string>;
@Translatable()
@Column({ type: "jsonb", default: {} })
description: Record<string, string>;
@Column()
slug: string;
}3. Use it in your service:
const item = new NewsItem();
item
.setTranslation("name", "en", "Breaking News")
.setTranslation("name", "ar", "أخبار عاجلة")
.setTranslation("name", "fr", "Dernières nouvelles");
item.slug = "breaking-news";
await repo.save(item);
// Get a translation
item.getTranslation("name", "en"); // 'Breaking News'
item.getTranslation("name", "ar"); // 'أخبار عاجلة'
// Get all translations
item.getTranslations("name");
// { en: 'Breaking News', ar: 'أخبار عاجلة', fr: 'Dernières nouvelles' }
// Get available locales
item.locales(); // ['en', 'ar', 'fr']The recommended way to use this package is with the built-in TranslatableMiddleware and TranslatableInterceptor. This gives you automatic locale-aware API responses with zero boilerplate in your controllers.
TranslatableMiddlewarereads theAccept-Languageheader and sets the locale for the request viaAsyncLocalStorageTranslatableInterceptor(already registered globally by the module) auto-resolves translatable fields in your API responses
With Accept-Language header — translatable fields are resolved to a single string in the requested locale:
// GET /products/1 — Accept-Language: ar
{ "id": 1, "name": "حاسوب محمول", "slug": "laptop" }Without Accept-Language header — translatable fields return the full JSON translation map:
// GET /products/1 — no Accept-Language header
{ "id": 1, "name": { "en": "Laptop", "ar": "حاسوب محمول" }, "slug": "laptop" }1. Apply the middleware in your AppModule:
import { MiddlewareConsumer, Module, NestModule } from "@nestjs/common";
import {
TranslatableModule,
TranslatableMiddleware,
} from "@nestbolt/translatable";
@Module({
imports: [
TranslatableModule.forRoot({
defaultLocale: "en",
fallbackLocales: ["en"],
}),
],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(TranslatableMiddleware).forRoutes("*");
}
}That's it! The TranslatableInterceptor is automatically registered as a global provider by TranslatableModule. Your controllers need no changes:
@Controller("products")
export class ProductController {
constructor(
@InjectRepository(Product)
private readonly repo: Repository<Product>,
) {}
@Get()
findAll() {
return this.repo.find();
}
@Get(":id")
findOne(@Param("id") id: number) {
return this.repo.findOneBy({ id });
}
}When the requested locale is missing for a field, the system tries each locale in the fallbackLocales chain in order:
TranslatableModule.forRoot({
fallbackLocales: ['en', 'fr', 'ar'], // tries each in order
})
// Request 'de', entity has { fr: 'Bonjour', ar: 'مرحبا' }
entity.getTranslation('name', 'de'); // 'Bonjour' — skipped 'en', found 'fr'Resolution order:
- The requested locale
- Each locale in
fallbackLocalesin order (default:[defaultLocale]) - Any available locale (if
fallbackAny: trueis configured) nullif no translation is found
The interceptor handles nested structures automatically:
// Paginated response
@Get()
async findAll() {
const [data, total] = await this.repo.findAndCount();
return { data, total };
}
// With Accept-Language: ar →
// { "data": [{ "name": "حاسوب محمول", ... }], "total": 5 }TranslatableModule.forRoot({
defaultLocale: "en",
fallbackLocales: ["en", "fr", "ar"],
fallbackAny: false,
});TranslatableModule.forRootAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
defaultLocale: config.get("DEFAULT_LOCALE"),
fallbackLocales: config.get<string[]>("FALLBACK_LOCALES"),
}),
inject: [ConfigService],
});The module is registered as global — you don't need to import it in every module.
TranslatableMixin is a function that extends any base class with translation methods:
// Extend BaseEntity
class Product extends TranslatableMixin(BaseEntity) {
/* ... */
}
// Extend a plain class
class Product extends TranslatableMixin(class {}) {
/* ... */
}| Method | Returns | Description |
|---|---|---|
setTranslation(key, locale, value) |
this |
Set a translation (chainable) |
setTranslations(key, translations) |
this |
Set multiple translations at once |
getTranslation(key, locale?, useFallback?) |
string | null |
Get translation for a locale |
getTranslations(key?, allowedLocales?) |
TranslationMap | Record<string, TranslationMap> |
Get all translations |
forgetTranslation(key, locale) |
this |
Remove a translation |
forgetAllTranslations(locale) |
this |
Remove a locale across all fields |
replaceTranslations(key, translations) |
this |
Replace all translations for a field |
hasTranslation(key, locale?) |
boolean |
Check if a translation exists |
getTranslatedLocales(key) |
string[] |
Get locales with translations |
locales() |
string[] |
Get all locales across all fields |
isTranslatableAttribute(key) |
boolean |
Check if a field is translatable |
getTranslatableAttributes() |
string[] |
Get all translatable field names |
getMissingLocales(key, locales) |
string[] |
Get locales missing for a field |
isFullyTranslated(locales) |
boolean |
Check all fields have all locales |
getTranslationCompleteness(locales) |
Record<string, Record<string, boolean>> |
Completeness report per field/locale |
Check which locales are missing translations — useful for admin dashboards and CI checks:
const item = new NewsItem();
item
.setTranslation("name", "en", "Hello")
.setTranslation("name", "ar", "مرحبا")
.setTranslation("description", "en", "A greeting");
// What's missing for a specific field?
item.getMissingLocales("name", ["en", "ar", "fr"]); // ['fr']
// Are all fields fully translated?
item.isFullyTranslated(["en", "ar"]); // false (description missing 'ar')
// Get a full report
item.getTranslationCompleteness(["en", "ar", "fr"]);
// {
// name: { en: true, ar: true, fr: false },
// description: { en: true, ar: false, fr: false }
// }Use @SkipTranslation() to bypass auto-resolution on specific routes. This is useful for admin panels that need the full JSON translation map for editing:
import { SkipTranslation } from "@nestbolt/translatable";
@Controller("products")
export class ProductController {
@Get()
findAll() {
return this.repo.find();
// With Accept-Language: ar → { "name": "حاسوب محمول" }
}
@SkipTranslation()
@Get("admin")
findAllAdmin() {
return this.repo.find();
// Always returns full JSON → { "name": { "en": "Laptop", "ar": "حاسوب محمول" } }
}
}You can also apply it to an entire controller:
@SkipTranslation()
@Controller("admin/products")
export class AdminProductController {
@Get()
findAll() {
return this.repo.find();
// Always returns full JSON, regardless of Accept-Language header
}
}The TranslatableInterceptor works seamlessly with GraphQL resolvers. It detects the execution context type automatically — no extra configuration needed.
Optional peer dependency: Install @nestjs/graphql to enable GraphQL support.
Pass the HTTP request through your GraphQL context (this is the standard NestJS pattern):
// app.module.ts
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
context: ({ req }) => ({ req }), // pass request to GQL context
});The interceptor reads Accept-Language from the underlying HTTP request headers, just like REST:
@Resolver(() => Product)
export class ProductResolver {
@Query(() => [Product])
products() {
return this.repo.find();
}
}# With Accept-Language: ar header
query { products { name slug } }
# → { "data": { "products": [{ "name": "حاسوب محمول", "slug": "laptop" }] } }@SkipTranslation() works on resolvers too:
@SkipTranslation()
@Query(() => [Product])
adminProducts() {
return this.repo.find();
// Always returns full JSON maps
}The interceptor checks context.getType():
'http'— readsAccept-Languagefrom the HTTP request'graphql'— readsAccept-Languagefromcontext.getArgs()[2].req.headers(the GraphQL context's underlying request)- Other types — passes data through without resolution
import { TranslatableService } from "@nestbolt/translatable";
@Injectable()
export class MyService {
constructor(private translatableService: TranslatableService) {}
doSomething() {
const locale = this.translatableService.getLocale();
const fallback = this.translatableService.getFallbackLocale();
}
}| Method | Returns | Description |
|---|---|---|
getLocale() |
string |
Get current locale (from AsyncLocalStorage or default) |
getDefaultLocale() |
string |
Get configured default locale |
getFallbackLocale() |
string |
Get first fallback locale (backward compat) |
getFallbackLocales() |
string[] |
Get the full fallback locale chain |
runWithLocale(locale, fn) |
T |
Execute a function with a specific locale context |
resolveLocale(requested, available, useFallback) |
string |
Resolve the best locale to use |
Use the @IsTranslations() decorator to validate translation map DTOs:
import { IsTranslations } from "@nestbolt/translatable";
class CreateNewsDto {
@IsTranslations({ requiredLocales: ["en"] })
name: Record<string, string>;
@IsTranslations()
description: Record<string, string>;
}Validation rules:
- Value must be a plain object
- All values must be strings (or null)
- Required locales must be present and non-empty
PostgreSQL jsonb query helpers for TypeORM SelectQueryBuilder:
import {
whereTranslation,
whereTranslationLike,
whereLocale,
whereLocales,
orderByTranslation,
} from "@nestbolt/translatable";
const qb = repo.createQueryBuilder("news");
// Filter by exact translation value
whereTranslation(qb, "name", "en", "Breaking News");
// Filter by pattern (ILIKE)
whereTranslationLike(qb, "name", "en", "%breaking%");
// Filter rows that have a translation in a locale
whereLocale(qb, "name", "en");
// Filter rows that have a translation in any of the locales
whereLocales(qb, "name", ["en", "fr"]);
// Order by translation value
orderByTranslation(qb, "name", "en", "ASC");
const results = await qb.getMany();If @nestjs/event-emitter is installed, TranslationHasBeenSetEvent is emitted whenever a translation is set:
import { OnEvent } from "@nestjs/event-emitter";
import { TranslationHasBeenSetEvent } from "@nestbolt/translatable";
@Injectable()
export class TranslationListener {
@OnEvent("translatable.translation-set")
handleTranslationSet(event: TranslationHasBeenSetEvent) {
console.log(
`${event.key}[${event.locale}]: ${event.oldValue} -> ${event.newValue}`,
);
}
}| Option | Type | Default | Description |
|---|---|---|---|
defaultLocale |
string |
'en' |
Default locale when none is set |
fallbackLocales |
string[] |
[defaultLocale] |
Ordered list of fallback locales to try when a translation is missing |
fallbackLocale |
string |
— | Shorthand for a single fallback (sets fallbackLocales: [value]). Deprecated. |
fallbackAny |
boolean |
false |
If true, fall back to any available locale when the chain is exhausted |
The mixin works without the module for basic use cases:
const entity = new Product();
entity.setTranslation("name", "en", "Laptop");
entity.getTranslation("name", "en"); // 'Laptop'Without TranslatableModule, locale resolution defaults to 'en' and events are not emitted.
npm testRun tests in watch mode:
npm run test:watchGenerate coverage report:
npm run test:covPlease see CHANGELOG for more information on what has changed recently.
Please see CONTRIBUTING for details.
If you discover any security-related issues, please report them via GitHub Issues with the security label instead of using the public issue tracker.
The MIT License (MIT). Please see License File for more information.