npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

nestjs-translatable

v1.2.0

Published

Database-aware translatable JSON columns for NestJS and TypeORM

Readme

NestJS Translatable

npm version license node

Database-aware translatable JSON columns for NestJS and TypeORM, inspired by Laravel Translatable.

Overview

Multi-language content usually ends up either duplicated across parallel columns (name_en, name_nl, name_fr, ...) or spread across a separate translations table with its own joins and repositories. nestjs-translatable takes a third approach: one JSON/JSONB column per translatable field, holding all locales in a single map ({ "en": "...", "nl": "...", "fr": "..." }), and a request pipeline that resolves the right locale automatically.

In practice that means:

  • @TranslatableColumn() — a decorator you drop onto any TypeORM entity property to mark it as translatable. It wraps @Column(), so it works wherever a column already works, and it needs no base entity or schema migration beyond a normal JSON column.
  • Automatic locale resolution — every request gets a locale worked out from ?lang=, an x-language header, Accept-Language, or your own custom resolvers, with a configurable fallback chain when a translation is missing.
  • Automatic response localization — controllers keep returning entities as-is; a global interceptor walks the response and replaces each translatable column with the resolved string for the current request's locale, with per-route opt-outs when you need the raw map instead.
  • SQL-level sorting and filtering — because the interceptor only localizes after the data is fetched, sorting or filtering by a translated value needs a small query-builder helper that generates parameterized COALESCE/JSON_EXTRACT expressions for Postgres and MySQL.

Supported backends: PostgreSQL (jsonb), MySQL/MariaDB (json), and MongoDB (embedded objects, no query-builder support — see below).

Features

  • @TranslatableColumn() composes TypeORM's @Column().
  • PostgreSQL jsonb, MySQL json, and MongoDB embedded-object storage.
  • Locale resolution from query, header, or Accept-Language, or custom resolvers.
  • Request context powered by AsyncLocalStorage.
  • Automatic response localization with route-level opt-out.
  • Configurable locale fallback chain.
  • Safe translation writes without replacing other locales.
  • Parameterized PostgreSQL and MySQL query expressions.
  • No required base entity.

Installation

npm install nestjs-translatable

The package uses NestJS, TypeORM, RxJS, and reflect-metadata as peer dependencies.

Configure the module

import { Module } from '@nestjs/common';
import { TranslatableModule } from 'nestjs-translatable';

@Module({
  imports: [
    TranslatableModule.forRoot({
      defaultLocale: 'en',
      fallbackLocale: 'en',
      supportedLocales: ['en', 'nl', 'fr'],
      responseMode: 'localized',
      fallbackStrategy: 'first-available',
    }),
  ],
})
export class AppModule {}

TranslatableModule is registered as a global module: it applies its locale-resolution middleware to every route and registers the response interceptor once for the whole app, so you only call forRoot/forRootAsync in your root module.

Registering the interceptor from main.ts instead

By default forRoot/forRootAsync register TranslationInterceptor for you via APP_INTERCEPTOR. If you'd rather attach it explicitly — e.g. to control its ordering relative to other global interceptors — pass registerInterceptor: false and attach it yourself in main.ts:

TranslatableModule.forRoot({
  // ...
  registerInterceptor: false,
});
import { NestFactory } from '@nestjs/core';
import { TranslationInterceptor } from 'nestjs-translatable';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalInterceptors(app.get(TranslationInterceptor));
  await app.listen(3000);
}

The locale-resolution middleware and services still come from TranslatableModule.forRoot() — only the interceptor's attachment moves.

Use forRootAsync when the options depend on other providers (e.g. reading supported locales from a config service):

TranslatableModule.forRootAsync({
  imports: [ConfigModule],
  inject: [ConfigService],
  useFactory: (config: ConfigService) => ({
    defaultLocale: config.get('DEFAULT_LOCALE'),
    supportedLocales: config.get('SUPPORTED_LOCALES'),
  }),
});

The default locale lookup order is:

  1. Custom localeResolvers (if configured)
  2. ?lang=nl
  3. x-language: nl
  4. Accept-Language
  5. configured default locale

Each candidate is normalized against supportedLocales (exact match → base language match, e.g. en-USen → default locale), unless strictLocales: false.

Define an entity

PostgreSQL

@Entity('products')
export class ProductEntity {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @TranslatableColumn({
    column: {
      type: 'jsonb',
      nullable: true,
    },
    translation: {
      fallbackLocale: 'en',
    },
  })
  details: TranslationMap;
}

Short form:

@TranslatableColumn('jsonb')
details: TranslationMap;

MySQL

@TranslatableColumn('json')
details: TranslationMap;

MongoDB

@TranslatableColumn()
details: TranslationMap;

The column type is explicit because decorators execute before Nest module configuration is initialized, so it can't be inferred from the configured driver at decoration time.

Store translations

{
  "en": "Product details",
  "nl": "Productdetails",
  "fr": "Détails du produit"
}

Read and write translations

@Injectable()
export class ProductsService {
  constructor(
    private readonly translations: TranslationService,
    @InjectRepository(ProductEntity)
    private readonly products: Repository<ProductEntity>,
  ) {}

  async setDutchDetails(id: string, value: string) {
    const product = await this.products.findOneByOrFail({ id });
    this.translations.set(product, 'details', 'nl', value);
    return this.products.save(product);
  }
}
  • set(entity, property, locale, value) writes a single locale without touching the others.
  • setMany(entity, property, values) writes several locales at once.
  • remove(entity, property, locale) deletes a single locale from the map.
  • resolve(value, locale?, fallbackLocale?, strategy?) resolves a translation map to a single value using the same candidate order as the response interceptor (exact locale → base language → fallback locale → module default locale), applying fallbackStrategy if nothing matches.

All of set/setMany/remove validate that the locale is in supportedLocales (unless strictLocales: false) and reject locales containing . or $ (unsafe for MongoDB embedded-document paths). They also throw if the target property no longer holds an object — see the mutateResponses footgun below.

Reading the current locale in a handler

@Get()
findAll(@Locale() locale: string) {
  // locale resolved for this request, e.g. 'nl'
}

Response formatting

An entity containing:

{
  "id": "123",
  "details": {
    "en": "Details",
    "nl": "Beschrijving"
  }
}

is returned for ?lang=nl as:

{
  "id": "123",
  "details": "Beschrijving"
}

Use route decorators to control formatting:

@Get()
findAll() {}

@Get('admin')
@RawTranslations()
findForAdmin() {}

@Get('edit')
@TranslationsWithRaw()
findForEditor() {}

@Get('export')
@SkipTranslation()
exportData() {}
  • @RawTranslations() returns the untouched translation map instead of a resolved string.
  • @TranslationsWithRaw() produces both details (resolved) and detailsTranslations (raw map).
  • @SkipTranslation() disables the interceptor entirely for that route.
  • @LocalizedTranslations() explicitly forces the default resolved-string behavior, useful when overriding a controller-level default.

DTOs and paginated responses (e.g. nestjs-paginate)

The interceptor detects translatable properties by walking the response object graph and checking each object's constructor against the metadata registered by @TranslatableColumn(). That works transparently for entity instances nested anywhere in the response — including inside arrays and wrapper objects like the { data, meta, links } shape nestjs-paginate returns — as long as the array items are still instances of the original entity class.

It stops working once those items are no longer the entity: for example if you define a separate Swagger response DTO for @ApiOkResponsePaginated(), or map entities through class-transformer's plainToInstance(). Those produce a different class (or, via ClassSerializerInterceptor, a plain object) that never had @TranslatableColumn() applied to it, so there's no metadata to find.

Use @TranslatableProperty() to register that DTO's property directly, without requiring TypeORM's @Column():

import { TranslatableProperty, TranslationMap } from 'nestjs-translatable';

export class ProductResponseDto {
  id: string;

  @TranslatableProperty()
  name: TranslationMap;

  price: string;
}

Now TranslationResponseTransformer resolves name to a localized string wherever a ProductResponseDto instance appears in the response — including inside a nestjs-paginate data array — the same way it already does for entities.

Query translated values

Normal entity reads are localized after hydration. Use TranslationQueryService when sorting or filtering by a translation in SQL:

const query = this.products.createQueryBuilder('product');

this.translationQuery.addSelect(
  query,
  {
    alias: 'product',
    property: 'details',
  },
  'translated_details',
);

this.translationQuery.orderBy(query, {
  alias: 'product',
  property: 'details',
});

return query.getMany();

The PostgreSQL adapter generates a parameterized COALESCE(column ->> locale, column ->> fallback) expression. The MySQL/MariaDB adapter uses COALESCE(JSON_UNQUOTE(JSON_EXTRACT(...)), ...). The adapter is picked automatically from builder.connection.options.type.

MongoDB applications should use aggregation expressions such as $ifNull because TypeORM's SQL QueryBuilder is not available for MongoDB — there is intentionally no MongoDB query adapter.

Migration generator

When you add @TranslatableColumn() to a column that used to be a plain scalar (or add a brand-new translatable column), generateTranslatableMigrations diffs your entities against the real database schema and writes a TypeORM migration that brings them in sync without losing data: existing scalar values are backfilled into the JSON column under one locale key before the old column is dropped, and the generated down() reverses it. Postgres and MySQL/MariaDB are supported, matching TranslationQueryService; there is no MongoDB migration adapter for the same reason there is no MongoDB query adapter.

It's a build-time tool, not part of the Nest module — it takes a TypeORM DataSource directly (the same one your app already uses for typeorm migration:run), so TranslatableModule doesn't need one injected.

Programmatic usage:

import { generateTranslatableMigrations } from 'nestjs-translatable';
import { dataSource } from './data-source';

await generateTranslatableMigrations({
  dataSource,
  supportedLocales: ['en', 'nl', 'fr'],
  defaultLocale: 'en',
  outputDir: './migrations',
});

Or via the bundled CLI, pointed at a compiled module that exports a DataSource (the same file you already point typeorm migration:run at):

npx nestjs-translatable migration:generate \
  --data-source ./dist/data-source.js \
  --locales en,nl,fr \
  --default-locale en \
  --output ./migrations

Add --dry-run to see the plan (per-column create/convert/skip and the would-be file name) without writing anything. A column that used to hold a value in a locale other than defaultLocale can override where its legacy value gets backfilled with a per-column sourceLocale:

@TranslatableColumn({
  column: { type: 'jsonb' },
  translation: { sourceLocale: 'de' },
})
name!: TranslationMap;

This tool only manages the single translatable column's type, nullability, and default — it's not a general schema-diff tool. Indexes, uniqueness constraints, or foreign keys on that column aren't touched, and it never manages brand-new tables (run a regular migration for those first).

Security

  • Supported locales are allow-listed by default.
  • MongoDB-unsafe locale characters (. and $) are rejected on writes.
  • SQL locale values are passed as parameters, never string-interpolated.
  • Query aliases and column names are validated as safe SQL identifiers.
  • The response transformer clones values by default to avoid saving localized strings over translation maps.
  • mutateResponses: true returns the same entity instances it received, so translatable columns are overwritten in place with the localized value. Only enable it for entity instances that are freshly fetched per request and discarded afterwards; never for cached or reused instances. TranslationService.set/remove throw if a property no longer holds a translation map, which surfaces this kind of accidental corruption instead of silently discarding other locales.
  • Every constructor dependency in this package is injected with an explicit @Inject(Token), rather than relying on TypeScript's implicit type-based injection. Implicit injection depends on the consuming build emitting design:paramtypes metadata (emitDecoratorMetadata); esbuild- and SWC-based toolchains (Vite, Vitest, @nestjs/cli --builder swc without the metadata plugin) commonly don't, which silently drops later constructor parameters instead of throwing. Explicit tokens work regardless of the consumer's build tool.

Development

npm test              # vitest run — fast, DB-free unit tests
npm run lint           # eslint
npm run typecheck      # tsc --noEmit
npm run build          # tsc -> dist/
npm run validate       # format:check + lint + typecheck + test + build

Integration tests against real Postgres/MySQL, plus a runnable demo app, live in example/:

docker compose up -d
cd example && npm install && npm test
docker compose down

License

MIT