Docs/Guides/ORM traits

Translatable

@veap/translatable stores multilingual field values for any model in one polymorphic translations table, so you can add translatable fields without a new table per model.

3 min read

@veap/translatable stores multilingual field values for any model in one polymorphic translations table, so you can add translatable fields without a new table per model.

Install and migrate#

Add the package as a workspace dependency, then run its migration so the translations table exists:

ts
import { runMigrations } from "@veap/database";
import { migrations } from "@veap/translatable/migrations";

await runMigrations("app", migrations);

Make a model translatable#

Extend TranslatableModel and declare the translatable fields:

ts
import { TranslatableModel } from "@veap/translatable";

export class BlogPost extends TranslatableModel {
  static override table = "blog_posts";
  static override translatable = ["title", "slug", "content"];
}

If your model already extends another base class, use the mixin instead:

ts
import { Translatable } from "@veap/translatable";
import { CustomBaseModel } from "./custom-base";

export class BlogCategory extends Translatable(CustomBaseModel) {
  static override table = "blog_categories";
  static override translatable = ["name", "description"];
}

Translatable fields are proxied: reading returns the active locale, and assigning a plain string stages the value for the active locale.

ts
post.setTranslation("title", "en", "Hello World");
post.setTranslation("title", "pl", "Witaj Świecie");

console.log(post.title); // active locale

// Set several locales at once
post.title = { en: "Hello World", pl: "Witaj Świecie" };

// Stage and persist
await post.save(); // translations are saved in the saved() hook

Read translations#

ts
post.getTranslation("title");           // active locale, with fallback
post.getTranslation("title", "pl");      // specific locale
post.getTranslations("title");           // { en: "...", pl: "..." }
post.getTranslations();                  // { title: { ... }, content: { ... } }

Fallback order: requested locale → fallback locale → default locale → the physical column on the model.

Manage locales#

ts
import {
  withLocale,
  setLocale,
  setFallbackLocale,
  getLocale,
} from "@veap/translatable";

setFallbackLocale("en");

await withLocale("de", async () => {
  console.log(getLocale()); // "de"
  console.log(post.title);  // German translation (or fallback)
});

withLocale() uses AsyncLocalStorage, so it scopes the locale across asynchronous work. Outside a withLocale() scope, setLocale() changes a process-global value — be careful in a shared server process, where concurrent requests would otherwise observe the same locale.

Query by translation#

ts
const posts = await BlogPost.query()
  .whereTranslation("slug", "witaj-swiecie", "pl")
  .get();

const matches = await BlogPost.query()
  .whereTranslationLike("title", "%Veap%")
  .get();

const ordered = await BlogPost.query()
  .orderByTranslation("title", "asc", "pl")
  .get();

The query builder eager-loads translations by default; call withoutTranslations() to opt out for a specific query.

UI#

@veap/translatable/ui provides editor components: TranslatableField (one field with a locale switcher), TranslatableTabs (locale tabs) and TranslatableFormCard (a card of fields keyed by locale). They operate on Record<locale, value> and default to ["pl", "en"]; pass your locales explicitly.

Caveats#

  • Saving translations issues additional writes (updateOrCreate/forceDelete per staged entry). Wrap multi-step operations in transaction() when atomicity matters.
  • Setting a translation to null deletes the stored row on save.
  • Provide a value for every locale you require, or rely on the fallback chain; a missing translation can otherwise resolve to null.

See also#

veap
Preparing0%