Skip to content

nestbolt/translatable

Repository files navigation

@nestbolt/translatable

JSON-based model translations for NestJS + TypeORM with locale-aware getters, fallback resolution, and query helpers.

npm version npm downloads tests license


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

Install the package via npm:

npm install @nestbolt/translatable

Or via yarn:

yarn add @nestbolt/translatable

Or via pnpm:

pnpm add @nestbolt/translatable

Peer 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.0

Optional:

  • Install @nestjs/event-emitter to enable translation change events
  • Install @nestjs/graphql to 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

  1. TranslatableMiddleware reads the Accept-Language header and sets the locale for the request via AsyncLocalStorage
  2. TranslatableInterceptor (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:

  1. The requested locale
  2. Each locale in fallbackLocales in order (default: [defaultLocale])
  3. Any available locale (if fallbackAny: true is configured)
  4. null if 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' — reads Accept-Language from the HTTP request
  • 'graphql' — reads Accept-Language from context.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 test

Run tests in watch mode:

npm run test:watch

Generate coverage report:

npm run test:cov

Changelog

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.

About

JSON-based model translations for NestJS + TypeORM with locale-aware getters, fallback resolution, and query helpers.

Topics

Resources

License

Contributing

Stars

Watchers

Forks

Contributors