drizzle-i18n

Strict Locales

Narrow locale keys at the type level and validate them at runtime with createI18n().

By default, all locale parameters accept string. This is flexible but means a typo like "ens" instead of "en" silently returns null translations. The createI18n() factory solves this by locking every helper to your exact set of supported locales.

Create a scoped helper set

import { createI18n } from "drizzle-i18n/pg";

const i18n = createI18n({
  defaultLocale: "en",
  locales: ["en", "ar", "fr"] as const,
  strict: true,
});

The as const on the locales array is what enables compile-time narrowing. Without it, the type widens to string[] and you lose the type safety.

What changes

Compile-time narrowing

Every locale argument becomes typed as "en" | "ar" | "fr" instead of string:

i18n.forLocale(categories.name, "ar");   // OK
i18n.forLocale(categories.name, "de");   // type error: '"de"' is not assignable to '"en" | "ar" | "fr"'

This applies to all helpers: forLocale, withTranslation, upsertTranslation, setTranslations, updateLocale, insertWithTranslations.

Runtime validation

When strict: true, passing an unknown locale throws at runtime with a clear error message:

drizzle-i18n: unknown locale "de" in forLocale. Valid locales: en, ar, fr

This catches issues even when TypeScript types are bypassed (e.g. locale strings coming from user input or API parameters).

Typed JSON locale maps

i18n.jsonTranslations() produces columns typed as LocaleMap<["en", "ar", "fr"], "en"> instead of Record<string, string>. The default locale key ("en") is required, and other locales are optional:

const categories = pgTable("categories", {
  id: serial("id").primaryKey(),
  ...i18n.jsonTranslations({
    name: { notNull: true },
  }),
});

// When inserting:
await db.insert(categories).values({
  name: { en: "Electronics" },                    // OK: default locale is required
});

await db.insert(categories).values({
  name: { en: "Electronics", ar: "إلكترونيات" },  // OK: other locales are optional
});

await db.insert(categories).values({
  name: { ar: "إلكترونيات" },                      // type error: 'en' is missing
});

Full example

import { pgTable, serial, text, integer } from "drizzle-orm/pg-core";
import { createI18n } from "drizzle-i18n/pg";

const i18n = createI18n({
  defaultLocale: "en",
  locales: ["en", "ar", "fr"] as const,
  strict: true,
});

// Schema
const products = pgTable("products", {
  id: serial("id").primaryKey(),
  sku: text("sku").notNull(),
  price: integer("price").notNull(),
});

const productI18n = i18n.translationTable(products, {
  name: text("name").notNull(),
  description: text("description"),
});

// Query -- locale arguments are type-checked
const rows = await i18n.withTranslation(db, products, productI18n, {
  locale: "ar",
  fallback: "en",
});

// Insert -- locale keys in translations are type-checked
await i18n.insertWithTranslations(db, products, productI18n, {
  values: { sku: "MUG-01", price: 15 },
  translations: {
    en: { name: "Mug" },
    ar: { name: "كوب" },
  },
});

All factory helpers

The object returned by createI18n() wraps every helper from the dialect entry point. Each one enforces the configured locale union:

HelperStrategyDescription
i18n.translationTable()Separate tableSame as top-level, but available on the factory
i18n.jsonTranslations()JSON columnProduces columns with LocaleMap typing
i18n.forLocale()JSON columnExtract one locale with type-checked key
i18n.withTranslation()Separate tableLocale-aware LEFT JOIN with type-checked locales
i18n.upsertTranslation()Separate tableUpsert one locale row with runtime validation
i18n.setTranslations()Separate tableBulk upsert with type-checked locale keys
i18n.updateLocale()JSON columnPatch one JSON key with type-checked locale
i18n.insertWithTranslations()Separate tableAtomic insert with type-checked locale keys

When to use createI18n()

Use the factory when:

  • Your app has a fixed, known set of locales
  • You want to catch locale typos at compile time
  • Locale strings come from user input and you want runtime validation as a safety net

Skip it when:

  • Locales are dynamic (loaded from a database or config file at runtime)
  • You are prototyping and do not want the overhead of as const everywhere

On this page