nestjs-translatable
v1.2.0
Published
Database-aware translatable JSON columns for NestJS and TypeORM
Maintainers
Readme
NestJS Translatable
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=, anx-languageheader,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_EXTRACTexpressions 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, MySQLjson, 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-translatableThe 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:
- Custom
localeResolvers(if configured) ?lang=nlx-language: nlAccept-Language- configured default locale
Each candidate is normalized against supportedLocales (exact match → base
language match, e.g. en-US → en → 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), applyingfallbackStrategyif 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 bothdetails(resolved) anddetailsTranslations(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 ./migrationsAdd --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: truereturns 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/removethrow 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 emittingdesign:paramtypesmetadata (emitDecoratorMetadata); esbuild- and SWC-based toolchains (Vite, Vitest,@nestjs/cli --builder swcwithout 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 + buildIntegration 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 downLicense
MIT
