@nestbolt/translatable
v0.4.0
Published
NestJS + TypeORM package for JSON-based model translations with locale-aware getters, fallback resolution, and query helpers
Maintainers
Readme
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" }Table of Contents
- 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
Installation
Install the package via npm:
npm install @nestbolt/translatableOr via yarn:
yarn add @nestbolt/translatableOr via pnpm:
pnpm add @nestbolt/translatablePeer Dependencies
This 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.0Optional:
- Install
@nestjs/event-emitterto enable translation change events - Install
@nestjs/graphqlto enable GraphQL resolver support
Quick Start
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']Automatic Locale Resolution (Middleware + Interceptor)
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.
How it works
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" }Setup
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 });
}
}Fallback Behavior
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
Wrapped Responses
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 }Module Configuration
Static Configuration (forRoot)
TranslatableModule.forRoot({
defaultLocale: "en",
fallbackLocales: ["en", "fr", "ar"],
fallbackAny: false,
});Async Configuration (forRootAsync)
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.
Using the Mixin
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 {}) {
/* ... */
}Translation Methods
| 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 |
Translation Completeness
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 }
// }Skip Translation (Admin Routes)
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
}
}GraphQL Support
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.
Setup
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
}How it works
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
Using the Service Directly
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 |
Validation
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
Query Helpers
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();Events
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}`,
);
}
}Configuration Options
| 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 |
Standalone Usage
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.
Testing
npm testRun tests in watch mode:
npm run test:watchGenerate coverage report:
npm run test:covChangelog
Please see CHANGELOG for more information on what has changed recently.
Contributing
Please see CONTRIBUTING for details.
Security
If you discover any security-related issues, please report them via GitHub Issues with the security label instead of using the public issue tracker.
Credits
License
The MIT License (MIT). Please see License File for more information.
