ObjectStackObjectStack

Internationalization standard for metadata

Translation bundles, locale resolution, pluralization, date/number formatting, and dynamic loading

Internationalization (i18n) Standard

Protocol spec, partial implementation. The runtime i18n service (@objectstack/service-i18n) currently implements translation bundles (loadTranslations / getTranslations), locale listing, fallback-locale resolution, and {{param}} interpolation via the II18nService contract (t(key, locale, params)) — see packages/services/service-i18n for the current API. The number/date/relative-time/currency formatting helpers, the plural() helper, ICU MessageFormat parsing, setUserLocale, and the nested config shapes shown below (e.g. i18n.translationService.provider, auto-translate hooks) describe target behaviour and are not yet implemented. Treat those snippets as design intent.

ObjectStack provides built-in internationalization that enables applications to support multiple languages, locales, and regional formats without external libraries or complex configuration.

The i18n Problem

Traditional i18n implementations require integrating multiple libraries:

// Typical i18n setup requires 3+ libraries
import i18next from 'i18next';           // Translation engine
import { formatNumber } from 'numeral';  // Number formatting
import { formatDate } from 'date-fns';   // Date formatting
import pluralize from 'pluralize';       // Pluralization

// Each library has different APIs, config, and quirks
i18next.t('messages.welcome', { name: 'John' });
formatNumber(1234.56, '0,0.00');
formatDate(new Date(), 'PP');
pluralize('item', 5);

Result: 500KB+ of dependencies, inconsistent APIs, and hours of configuration.

ObjectStack i18n Solution

ObjectStack provides a unified i18n API with batteries included:

// Today: translation + interpolation via the II18nService contract
const i18n = kernel.getService('i18n');

i18n.t('messages.welcome', 'en', { name: 'John' }); // Translation + interpolation

// Design intent (not yet implemented): unified locale-aware formatting helpers
//   formatNumber(1234.56)        — locale-aware number formatting
//   formatDate(new Date(), 'medium') — locale-aware date formatting
//   plural('item', 5)            — pluralization

Benefits:

  • ✅ Zero external dependencies
  • ✅ Consistent API across all i18n operations
  • ✅ Automatic locale detection
  • ✅ Plugin-based translation bundles
  • ✅ Less than 50KB total footprint

Locale Resolution

ObjectStack automatically determines the user's locale using a fallback chain:

Partial implementation. The server resolves the request locale from the Accept-Language header (highest-priority entry), then a ?locale= query parameter, then the i18n service's default locale — see extractLocale in packages/rest/src/rest-server.ts. The DB-persisted user preference, tenant default, and IP geolocation steps shown below are design intent: there is no geolocation or tenant-locale resolution in the runtime today.

┌─────────────────────────────────────────────────────────────┐
│ 1. USER PREFERENCE                                          │
│    Explicit user setting (saved in database)                │
│    Example: User selects "Deutsch" in profile settings      │
└─────────────────────────────────────────────────────────────┘
                          ↓ fallback
┌─────────────────────────────────────────────────────────────┐
│ 2. TENANT DEFAULT                                           │
│    Organization-wide locale setting                         │
│    Example: German company sets 'de' as tenant default      │
└─────────────────────────────────────────────────────────────┘
                          ↓ fallback
┌─────────────────────────────────────────────────────────────┐
│ 3. ACCEPT-LANGUAGE HEADER                                   │
│    Browser sends preferred languages                        │
│    Example: 'de-AT, de;q=0.9, en;q=0.8'                     │
└─────────────────────────────────────────────────────────────┘
                          ↓ fallback
┌─────────────────────────────────────────────────────────────┐
│ 4. IP GEOLOCATION                                           │
│    Detect country from IP address                           │
│    Example: IP from Austria → 'de-AT'                       │
└─────────────────────────────────────────────────────────────┘
                          ↓ fallback
┌─────────────────────────────────────────────────────────────┐
│ 5. SYSTEM DEFAULT                                           │
│    Configured in objectstack.config.ts                      │
│    Example: 'en' for US-based companies                     │
└─────────────────────────────────────────────────────────────┘

Locale Format

Locales follow BCP 47 standard: language-REGION

Examples:

  • en — English (generic)
  • en-US — English (United States)
  • en-GB — English (United Kingdom)
  • de — German (generic)
  • de-DE — German (Germany)
  • de-AT — German (Austria)
  • zh-CN — Chinese (Simplified, China)
  • zh-TW — Chinese (Traditional, Taiwan)

Locale Fallback

If exact locale not found, ObjectStack falls back to language-only locale:

User requests: de-AT (German, Austria)
  ↓
Check for translation: de-AT ✗ (not found)
  ↓
Fallback to: de ✓ (found)
  ↓
Use: de (German generic)

If language not found, fall back to system default:

User requests: pt-BR (Portuguese, Brazil)
  ↓
Check for translation: pt-BR ✗ (not found)
  ↓
Fallback to: pt ✗ (not found)
  ↓
Fallback to: en (system default) ✓

The "system default" step is the stack's declared fallback (i18n.fallbackLocale, else defaultLocale), never a literal en. For metadata labels one more rule applies (#15711): the inline label: authored on the metadata is the default locale's text, so a request for defaultLocale stops at the authored label without consulting any other locale's bundle — a zh-CN workspace with a courtesy en bundle serves Chinese to zh-CN and English to en. See Translations.

Display Label Forms

Every display label this protocol declares — an object's label, a field's label / help / placeholder, a view title, a component's title, an ariaLabel — is an I18nLabel (I18nLabelSchema, packages/spec/src/ui/i18n.zod.ts). The schema authorizes two forms, and a conforming producer, renderer or tool must handle both:

FormAuthored asWhere its other locales liveResolver
1 — plain stringlabel: 'Members'a translation bundle, under the key derived from the label's position in the metadatasystem/i18n-resolver.ts
2 — inline locale maplabel: { en: 'Members', 'zh-CN': '成员' }nowhere else — the map is every locale it carriesresolveI18nLabel (ui/i18n-label-resolver.ts); pickLocalized in ObjectUI

Both are real; neither is deprecated by this schema.

— I18nLabelSchema, packages/spec/src/ui/i18n.zod.ts

A caller holding a label that may be either form resolves form 2 first, then looks the resulting string up in the bundle.

Form 1 is one locale's text

A plain string is the default locale's source text — the rule Locale Fallback already states from the resolution side: a request for defaultLocale stops at the authored label and consults no bundle. Every other locale is reached through a bundle entry, and that entry's key comes from where the label sits in the metadata (objects.<object>._views.<view>.label, …) — never from the string the author wrote.

Form 2 is several locales' text at once

An inline locale map carries each locale's text on the metadata document itself. The renderer tag-matches the requested locale against the map's own keys and consults no bundle for that prop. Keys are BCP-47-shaped language tags (en, zh, zh-CN, pt-BR, zh-Hans-CN) or the literal default, read as the untagged fallback entry.

key and defaultValue are rejected by name as map keys. They spell the retired key-reference form, which no resolver ever looked up: both resolvers fall through to the first string value, so a label authored that way rendered its own raw dotted key on screen, in every locale.

For the props that have no bundle key at all, the map is the only localisation route — a document localised that way is fully localised.

A map is never extracted, and no bundle key is synthesised for one. os i18n extract scaffolds no row for a map and no key family exists for one; in particular no key is derived from a node's position in the component tree (maintainer ruling 2026-09-03, #14749), because an array index promoted to a bundle key would turn reordering two sibling components into a silent, all-green swap of their translations. The cost falls on the author: a translator working from the bundles will not find a map's strings, so every locale a map is to serve is one its author writes into the map.

Which form to choose, and what each costs a translation workflow, is Translations.

Translation Bundles

Translations are stored in JSON files organized by locale and namespace.

Object-First Convention

ObjectStack uses an object-first convention where all translatable metadata for an object is aggregated under objects.{object_name}. Global (non-object-bound) translations remain in dedicated top-level groups. This aligns with Salesforce DX and Dynamics conventions, enabling efficient translation workbench editing and automated coverage detection.

There is exactly one shape. A file-authored bundle is a map of locale code → TranslationData; a translation metadata item authored at runtime is one TranslationData plus the locale it translates. The resolvers, os i18n extract, os i18n check, and the Studio editor all read the same keys, and every key in this shape is derived from where a label sits in the metadata — never from the label's own value. Two dialects that once appeared alongside it are retired and read by nothing: the o.{object} shape below, and the flat <namespace>.<prop> shape recorded under ObjectQL Integration.

// One locale of a TranslationBundle (e.g. zh-CN)
const zh: TranslationData = {
  // ── Object-first translations ─────────────────────────────────
  objects: {
    account: {
      label: '客户',
      pluralLabel: '客户',
      description: '客户管理对象',
      fields: {
        name: { label: '客户名称', help: '公司法定名称' },
        industry: { label: '行业', options: { tech: '科技', finance: '金融' } },
        status: { options: { active: '活跃', inactive: '停用' } },
      },
      _views:  { all_accounts: { label: '全部客户' } },
      _sections: { basic_info: { label: '基本信息' } },
      _actions: { convert: { label: '转换', confirmText: '确认转换?' } },
    },
  },

  // ── Global translations ───────────────────────────────────────
  apps: { crm: { label: '客户关系管理', navigation: { home: { label: '首页' } } } },
  dashboards: { sales_overview: { label: '销售概览' } },
  pages: { landing: { title: '欢迎' } },
  globalActions: { export_csv: { label: '导出 CSV' } },
  messages: { commonSave: '保存' },
};

Key benefits:

  • ✅ All translatable content for one object in one place
  • ✅ CLI can generate translation skeletons per object
  • ✅ Workbench can show per-object coverage and diffs
  • ✅ One shape for files and runtime authoring — what you author is what renders

Retired: the o.{object} dialect. A second, object-first shape keyed on o. (with app, nav, dashboard, _globalOptions, _meta) was once documented here for runtime-authored translations. No resolver ever read it, so translations authored in that shape saved successfully and rendered nothing. It was removed in #3778, which rejected those ten keys at the metadata door; #4001 closed both shapes entirely, so any undeclared key is now rejected in a runtime item and in a file-authored bundle alike, with the retired ones still naming the group to use instead. Author everything — files and runtime items alike — under objects..

Authoring a translation item at runtime

An admin (or an agent using the metadata API) authors one item per locale. The only difference from a file bundle is the top-level locale:

import { defineTranslation } from '@objectstack/spec/system';

export default defineTranslation({
  locale: 'zh-CN',
  objects: {
    account: {
      label: '客户',
      fields: { name: { label: '客户名称' } },
    },
  },
  messages: { commonSave: '保存' },
});

locale is required — the runtime sync skips an item whose locale it cannot resolve, and a silent skip is exactly what makes a missing translation hard to diagnose. Items are merged over the static file bundles, so an authored value wins over a shipped one for the same key.

Where a bundle lives

A bundle is a TypeScript module handed to defineStack, not a directory the loader walks. defineTranslationBundle validates it at authoring time, and how you split those modules across your source tree is your own convention:

fileOrganization was removed — no runtime ever read it; how you split bundle files is a convention of your source tree, and the loader reads whatever translations you hand defineStack

— TranslationConfigSchema guidance, packages/spec/src/system/translation.zod.ts

One directory is read, by the file adapter. I18nServicePlugin({ localesDir }) loads one JSON file per locale, named for the locale, each holding a whole TranslationData:

my-plugin/
  i18n/
    en.json              # the `en` entry — one whole TranslationData
    de.json
    zh-CN.json

The directory is read with a .json suffix filter and the locale is the file's own basename, so a per-namespace subdirectory (i18n/en/account.json) loads nothing: the entries are directories, no locale is registered, and every lookup returns the key it was given — the raw dotted key renders on screen, in every locale.

Translation File Format

One locale of a bundle, as JSON — the same groups a TranslationData declares:

// i18n/en.json
{
  "objects": {
    "account": {
      "label": "Account",
      "pluralLabel": "Accounts",
      "fields": {
        "name": { "label": "Account Name", "help": "Legal company name" },
        "industry": {
          "label": "Industry",
          "options": { "technology": "Technology", "finance": "Finance" }
        }
      },
      "_views": { "all_accounts": { "label": "All Accounts" } }
    }
  },
  "messages": {
    "welcome": "Welcome, {{name}}!",
    "accountCount": "You have {{count}} accounts"
  }
}
// i18n/de.json
{
  "objects": {
    "account": {
      "label": "Konto",
      "pluralLabel": "Konten",
      "fields": {
        "name": { "label": "Kontoname", "help": "Offizieller Firmenname" },
        "industry": {
          "label": "Branche",
          "options": { "technology": "Technologie", "finance": "Finanzen" }
        }
      },
      "_views": { "all_accounts": { "label": "Alle Konten" } }
    }
  },
  "messages": {
    "welcome": "Willkommen, {{name}}!",
    "accountCount": "Sie haben {{count}} Konten"
  }
}

Three rules the shape enforces, all of them closed since #4001:

  • The top level is the declared group set and nothing else — objects, apps, messages, globalActions, dashboards, datasets, pages, flows, metadataForms, settingsCommon. A group invented beside them ("account", "fields", "list", "industries") is rejected by name at both authoring doors, with the group to use instead named in the rejection.
  • settings is a PLATFORM group and an application bundle may not carry it. It is keyed by SettingsManifest.namespace, and only platform code declares a manifest — so the only namespaces an application could ever address are the platform's own. Writing it in stack.translations (or in defineTranslationBundle) is refused by name with that prescription, and so is writing it on a translation metadata item (defineTranslation, or a save through the metadata API) — the item door takes the same per-app face; the platform's own bundles author it against PlatformTranslationData, and the served document (GET /i18n/translations/:locale) carries it because it is the merge of every loaded bundle.
  • Field options are a map keyed by the option's stored value — never an array of { value, label } pairs, and never the display label. See Orphan Keys and Option Keys.
  • {{param}} substitution is the only transformation t() performs. There is no ICU engine, no CLDR plural selection, and no {{date, datetime}} / {{amount, currency}} formatter — a key_plural sibling is an ordinary key that nothing selects. See Pluralization.

Metadata authoring forms (metadataForms)

The Studio property panels that author a metadata document (an object, a dashboard, a flow, …) are laid out by the type's authoring form (dashboardForm, …, METADATA_FORM_REGISTRY) and render field shapes from the JSON Schema derived from its zod schema. Those forms are authored in English; a bundle localises them under metadataForms.<type>:

metadataForms: {
  dashboard: {
    label: '仪表板',
    sections: { layout: { label: '布局' } },          // section by its slugged label
    fields: {
      columns: { label: '列数', helpText: '栅格列数' }, // a top-level form field
      'header.showTitle': { label: '显示标题' },        // a composite's child
      'header.actions': { label: '操作按钮' },          // a repeater
      'header.actions.label': { label: '标签' },        // a property of each repeater ROW
    },
  },
}

A field path is the dot path from the form root, and a repeater row property is <repeater>.<property> — no items segment (fields.items.label would name a declared child called items). The keys are emitted by os i18n extract from the children the form declares under a composite or repeater (fields: [...]), so a child the form does not enumerate has no key; enumerate all of a composite's children or none, because the panel prefers a declared list over the schema-derived one.

Two objects consume these entries, and a localised name reaches the panel only through the one the renderer reads for that node:

NodeWhat the panel readsResolver
a form field, a composite's childFormFieldSpec.label / helpText / placeholderresolveMetadataFormLabels(form, type, bundle, opts)
a property of a repeater row (a table column header)the JSON Schema items.properties[k].titleresolveMetadataFormSchemaTitles(schema, type, bundle, opts)

Both run in GET /api/v1/meta, which serves every type's form and schema already localised for the request's locale; a client deriving the schema itself with z.toJSONSchema applies the second one to its own copy. Only label crosses over to the schema (as title); helpText and placeholder stay on the form.

Naming an item-level property therefore has three parts, none of which is a locale-catalog entry alone: the English name is a .meta({ title: 'Action URL' }) on the zod item schema (DashboardHeaderActionSchema), because that is the object the table header reads and its fallback — the raw key — runs in every locale, English included; the form declares the child with the same label, so the extractor emits fields['header.actions.actionUrl'] and the platform catalogs carry a translation for it; and a locale bundle's entry at that path is what the overlay writes back as the node's title.

Translation API

Basic Translation

The contract is t(key, locale, params?) — the target locale is always passed explicitly, and the key itself is returned when no translation is found (there is no defaultValue option). The key is a dot path into TranslationData, so it starts at one of the eleven declared groups.

const i18n = kernel.getService('i18n');

// Translate a key for a given locale, with {{param}} interpolation
const message = i18n.t('messages.welcome', 'en', { name: 'John' });
// en: "Welcome, John!"
// de (locale 'de'): "Willkommen, John!"

// The dot path walks the group structure to a string leaf
const fieldLabel = i18n.t('objects.account.fields.name.label', 'de');
// "Kontoname"

// Missing keys fall back to the fallbackLocale, then return the key string
const label = i18n.t('objects.account.fields.unknown.label', 'en');
// "objects.account.fields.unknown.label"

Because the path is walked segment by segment, a messages id that itself contains a dot is unreachable through t(): messages is a flat Record<string, string>, so { 'common.save': 'Save' } is one key named common.save, while t('messages.common.save', …) looks for a nested common object and finds none. Keep message ids single-segment (messages.commonSave) — the rule packages/plugins/plugin-audit/src/translations/messages.ts states for its own bundle, for the same reason.

Pluralization

Design intent. Automatic CLDR plural selection (the key / key_plural convention) is not yet implemented by the file adapter — t() performs only {{param}} interpolation. To pluralize today, select the plural form in application code before calling t().

// Target behaviour (not yet wired into the file adapter):
i18n.t('messages.itemCount', 'en', { count: 1 }); // "1 item"
i18n.t('messages.itemCount', 'en', { count: 5 }); // "5 items"

Planned plural forms:

  • English: key, key_plural
  • Other languages may have more forms (e.g., Polish has 5)

The intent is to use Unicode CLDR plural rules to select the correct form.

Context-Based Translation

// Distinct keys for distinct contexts — both resolved with t(key, locale)
const deleteButton = i18n.t('messages.deleteButton', 'en');
// "Delete"

const deleteConfirmation = i18n.t('messages.deleteConfirm', 'en', {
  name: 'Acme Corp',
});
// "Are you sure you want to delete Acme Corp?"

Date and Time Formatting

Design intent — not yet implemented. The formatDate, formatTime, formatDateTime, and formatRelativeTime methods are not part of the current II18nService contract (packages/spec/src/contracts/i18n-service.ts) and have no runtime implementation in @objectstack/service-i18n. The examples below describe the planned locale-aware formatting API; until it ships, use the platform Intl.DateTimeFormat / Intl.RelativeTimeFormat APIs directly.

The planned API provides locale-aware date/time formatting using Unicode CLDR data.

Date Formatting

const date = new Date('2024-01-15T14:30:00Z');

// Predefined formats
context.i18n.formatDate(date, 'short');
// en-US: "1/15/24"
// en-GB: "15/01/24"
// de-DE: "15.01.24"

context.i18n.formatDate(date, 'medium');
// en-US: "Jan 15, 2024"
// de-DE: "15. Jan. 2024"

context.i18n.formatDate(date, 'long');
// en-US: "January 15, 2024"
// de-DE: "15. Januar 2024"

context.i18n.formatDate(date, 'full');
// en-US: "Monday, January 15, 2024"
// de-DE: "Montag, 15. Januar 2024"

// Custom format (ICU pattern)
context.i18n.formatDate(date, { pattern: 'yyyy-MM-dd' });
// "2024-01-15" (same across locales)

Time Formatting

const time = new Date('2024-01-15T14:30:00Z');

context.i18n.formatTime(time, 'short');
// en-US: "2:30 PM"
// de-DE: "14:30"

context.i18n.formatTime(time, 'medium');
// en-US: "2:30:00 PM"
// de-DE: "14:30:00"

context.i18n.formatTime(time, 'long');
// en-US: "2:30:00 PM GMT"
// de-DE: "14:30:00 GMT"

DateTime Formatting

const dateTime = new Date('2024-01-15T14:30:00Z');

context.i18n.formatDateTime(dateTime, 'short');
// en-US: "1/15/24, 2:30 PM"
// de-DE: "15.01.24, 14:30"

context.i18n.formatDateTime(dateTime, 'medium');
// en-US: "Jan 15, 2024, 2:30:00 PM"
// de-DE: "15. Jan. 2024, 14:30:00"

Relative Time

const pastDate = new Date(Date.now() - 3600000); // 1 hour ago

context.i18n.formatRelativeTime(pastDate);
// en: "1 hour ago"
// de: "vor 1 Stunde"

const futureDate = new Date(Date.now() + 86400000); // 1 day from now

context.i18n.formatRelativeTime(futureDate);
// en: "in 1 day"
// de: "in 1 Tag"

Timezone Handling

// Format in specific timezone
context.i18n.formatDateTime(date, 'medium', {
  timeZone: 'America/New_York',
});
// "Jan 15, 2024, 9:30:00 AM"

// Format in user's timezone (from context)
context.i18n.formatDateTime(date, 'medium', {
  timeZone: context.user.timezone, // e.g., 'Europe/Berlin'
});

Number Formatting

Design intent — not yet implemented. formatNumber, formatCurrency, and formatPercent are not part of the current II18nService contract and have no runtime implementation. The examples below describe the planned API; until it ships, use Intl.NumberFormat directly.

The planned API provides locale-aware number formatting for decimals, currency, and percentages.

Decimal Numbers

const number = 1234567.89;

context.i18n.formatNumber(number);
// en-US: "1,234,567.89"
// de-DE: "1.234.567,89"
// fr-FR: "1 234 567,89"

// Control decimal places
context.i18n.formatNumber(number, {
  minimumFractionDigits: 2,
  maximumFractionDigits: 2,
});
// en-US: "1,234,567.89"

Currency

const amount = 1234.56;

context.i18n.formatCurrency(amount, 'USD');
// en-US: "$1,234.56"
// de-DE: "1.234,56 $"
// ja-JP: "$1,234.56"

context.i18n.formatCurrency(amount, 'EUR');
// en-US: "€1,234.56"
// de-DE: "1.234,56 €"
// fr-FR: "1 234,56 €"

// Control symbol display
context.i18n.formatCurrency(amount, 'USD', {
  currencyDisplay: 'code',
});
// "USD 1,234.56"

context.i18n.formatCurrency(amount, 'USD', {
  currencyDisplay: 'name',
});
// "1,234.56 US dollars"

Percentages

const percent = 0.1234;

context.i18n.formatPercent(percent);
// en-US: "12.34%"
// de-DE: "12,34 %"

// Control decimal places
context.i18n.formatPercent(percent, {
  minimumFractionDigits: 0,
  maximumFractionDigits: 0,
});
// en-US: "12%"

Compact Notation

const bigNumber = 1234567;

context.i18n.formatNumber(bigNumber, {
  notation: 'compact',
});
// en-US: "1.2M"
// de-DE: "1,2 Mio."

const smallNumber = 1234;

context.i18n.formatNumber(smallNumber, {
  notation: 'compact',
  compactDisplay: 'short',
});
// en-US: "1.2K"

Plugin Integration

Translations are metadata, declared on the stack — not manifest entries. (The former contributes.translations manifest key was removed in v17 (#10724, ADR-0049): no loader ever read its { locale, path } entries, so a manifest that still carries it now fails the parse with the upgrade prescription.)

A package declares a translation bundle and registers it in its stack's translations collection:

// src/translations/crm.translation.ts
import { defineTranslationBundle } from '@objectstack/spec/system';

export const CrmTranslationBundle = defineTranslationBundle({
  en: {
    objects: {
      crm_account: {
        label: 'Account',
        pluralLabel: 'Accounts',
        fields: {
          name: { label: 'Account Name' },
        },
      },
    },
  },
  de: {
    objects: {
      crm_account: {
        label: 'Konto',
        pluralLabel: 'Konten',
        fields: {
          name: { label: 'Kontoname' },
        },
      },
    },
  },
});
// objectstack.config.ts
import { defineStack } from '@objectstack/spec';
import { CrmTranslationBundle } from './src/translations/crm.translation.js';

export default defineStack({
  manifest: {
    id: 'com.mycompany.crm',
    version: '1.0.0',
    type: 'app',
    name: 'CRM',
  },
  translations: [CrmTranslationBundle],
});

The engine registers the collection as translation metadata (the governed translation type), and the i18n pipeline serves it.

Using Plugin Translations

// In plugin code — resolve the i18n service from the kernel/context,
// then pass the target locale explicitly: t(key, locale, params?)
const i18n = ctx.getService('i18n');

// The key addresses the bundle declared above — every plugin's bundle is
// deep-merged into the same per-locale TranslationData, so the object's own
// name is what separates them, not a per-plugin prefix.
const label = i18n.t('objects.crm_account.fields.name.label', 'en');
// en: "Account Name"
// de (locale 'de'): "Kontoname"

// Cross-plugin translation access — keys from any installed plugin resolve
const welcome = i18n.t('messages.crmWelcome', 'en');
// Works if @mycompany/crm plugin is installed

ObjectQL Integration

ObjectQL objects and fields are automatically translated, and the author writes no keys at all: the label is form 1 — plain source-locale text — and the bundle entry is addressed by where that label sits in the metadata.

// Object definition — labels are the default locale's TEXT, not keys
import { ObjectSchema, Field } from '@objectstack/spec/data';

export const Account = ObjectSchema.create({
  name: 'account',
  sharingModel: 'private',
  label: 'Account',
  pluralLabel: 'Accounts',
  icon: 'building',

  fields: {
    name: Field.text({
      label: 'Account Name',
    }),

    industry: Field.select({
      label: 'Industry',
      options: [
        { value: 'technology', label: 'Technology' },
        { value: 'finance', label: 'Finance' },
      ],
    }),
  },
});
// i18n/de.json — keyed by position: objects.<object>.fields.<field>.label,
// and an option map keyed by the option's stored `value`
{
  "objects": {
    "account": {
      "label": "Konto",
      "pluralLabel": "Konten",
      "fields": {
        "name": { "label": "Kontoname" },
        "industry": {
          "label": "Branche",
          "options": { "technology": "Technologie", "finance": "Finanzen" }
        }
      }
    }
  }
}

The key comes from the label's position, never from the label's value. resolveObjectFieldLabels and its siblings in packages/spec/src/system/i18n-resolver.ts build objects.<object>.fields.<field>.label from the object and field names and look that up; the authored string is only ever the fallback they return when no bundle carries the key. So the account object above renders "Account Name" in en straight off the metadata and "Kontoname" in de out of the bundle — with nothing dotted stored on the metadata at all.

Retired: the flat <namespace>.<prop> dialect. Until this page was corrected, these two sections authored label: 'account.fields.name' and paired it with a per-namespace bundle file (i18n/en/account.json) holding { "fields": { "name": "Account Name" } }. Neither half is read by anything: TranslationDataSchema rejects label / fields / list / industries as top-level groups at both authoring doors (#4001), and the file adapter loads only <locale>.json from localesDir, so a per-namespace subdirectory registers no locale at all. A label authored that way rendered its own raw dotted key on screen, in every locale — the same failure the retired { key, defaultValue } map form produced.

ObjectUI Integration

ObjectUI automatically translates labels, placeholders, and error messages, on the same positional keys — a view's own label under objects.<object>._views.<view>.label, keyed by the bare authoring key, never by the <object>.<key> identity the registry assigns the served document. Mind which key that is: listViews / formViews entries keep the key their author wrote, while an unnamed default list is expanded as default (and a default form as form), so the view below is addressed at _views.default — _views.list resolves nothing.

// View definition — `defineView` takes a container of named views
// ({ list, form, listViews, formViews }); a flat { name, type, ... }
// object is rejected at build time.
export default defineView({
  list: {
    type: 'grid',
    label: 'Accounts', // source-locale text, not a key
    data: { provider: 'object', object: 'account' },
    columns: [
      'name', // Column label auto-translated from the ObjectQL field definition
    ],
  },
});
// i18n/de.json
{
  "objects": {
    "account": {
      "_views": { "default": { "label": "Konten" } }
    }
  }
}

Dynamic Locale Switching

The i18n service resolves translations per call — t(key, locale, params) takes the target locale as an explicit argument. The persisted user-locale preference and a UI "switch locale without reload" flow are design intent, not yet exposed as service methods (there is no setUserLocale or context.ui.reload).

The II18nService contract translates against whatever locale you pass in, so "switching" a locale means resolving the same keys against a different locale code:

const i18n = kernel.getService('i18n');

// Resolve the same key against different locales
i18n.t('messages.welcome', 'en'); // "Welcome!"
i18n.t('messages.welcome', 'de'); // "Willkommen!"

The locale a request renders in comes from the application's locale-resolution chain (user preference → tenant default → Accept-Language → system default); persisting a user's choice is left to the host application.

Translation Management

CLI Tools

The os CLI ships two i18n commands: extract scaffolds per-locale translation skeletons from your stack config, and check reports coverage / missing keys.

# Scaffold per-locale translation skeletons from the stack config
os i18n extract --locales=zh-CN,ja-JP --out=./src/translations

# Print the skeletons as JSON instead of writing files
os i18n extract --json

# Detect missing translation keys across all configured locales
os i18n check

# Fail CI when any locale falls below a coverage threshold
os i18n check --strict --threshold=95

Translation Coverage

# Check translation coverage
os i18n check

Output:
  en: 100% (450/450 keys)
  de: 95%  (428/450 keys) - Missing: 22 keys
  es: 80%  (360/450 keys) - Missing: 90 keys
  
  Missing keys in de:
    - account.fields.new_field
    - account.actions.bulk_delete
    ...

The unit is one key per locale, and for a key authored as an inline locale map the locales that count are the map's own. os i18n check and os lint compute the same report, so this holds for both:

What the metadata carries at a keyThe default localeA locale the map carriesA locale the map omits
nothing — the prop is absentnot counted: the key leaves the denominator entirely——
a plain stringcovered — the string is the default locale's textn/a: only a bundle entry covers another localemissing
an inline locale mapcoveredcovered, with no bundle entrymissing — reported like any other gap

A help written { en, 'zh-CN' } under supportedLocales: ['en', 'zh-CN', 'ja-JP'] therefore reports exactly one gap, ja-JP, and adding 'ja-JP' to the map closes it. There is no bundle row to write for it: no bundle key exists for a map.

Orphan Keys and Option Keys

os i18n check runs in one direction — which keys the metadata expects that nothing supplies text for, whether that text would come from a bundle or from an inline locale map. The reverse direction (keys a bundle carries that no metadata claims) is checked by os validate, os lint, and os compile, which walk every bundle against the stack it ships with.

"Declared" below has one meaning in every row: declared by the stack being checked or by a sibling package of the same artifact. On a single defineStack project, and on the union pass over an artifact, those are the same thing. On the per-package pass over an artifact's packages[] (see Validating metadata) each package is checked alone, but with the artifact's packages[] as resolution context — so a package may translate an object, view, page, action, app, dashboard or flow a sibling package declares. The keys beneath it are then judged against the sibling's own declaration, not waved through: a typo under a sibling's object is still an error. Where the package being checked and a sibling declare the same name, the package's own record is the one a field's options, an action's params, a dashboard's widgets and header actions, or a flow's screens are judged against; every other name the two declare adds up.

A row that starts {action}. holds at both action addresses, objects.{object}._actions.{action} and globalActions.{action}: the keys beneath an action entry are judged the same way under either.

KeyResolves when it names
objects.{object}an object declared by this stack or a sibling package of the same artifact — its keys are then judged row by row below; or a platform object, or an object an objectExtensions[] entry extends that no package of the artifact declares — both skipped whole, because the owner's fields are not visible from here
objects.{object}.fields.{field}a field that object declares, a field an objectExtensions[] entry merges into it, or a system column the registry injects (id, owner_id, the audit and tenant columns; _id, name and space are accepted too)
objects.{object}.fields.{field}.options.{key}an option's stored value
objects.{object}._views.{view}the key the view registry assigns a view over that object: a listViews / formViews map key, or the default list's name — default when it has none (see ObjectUI Integration)
objects.{object}._sections.{section}a fieldGroups[].key, a named section of a form view over that object (a formViews entry or the default form), or a named section of a record:details component on a page bound to it
objects.{object}._tabs.{tab}a filter-preset tab's name in interfaceConfig.userFilters.tabs on a page over that object (interfaceConfig.source, else the page's object) — a list view's own tabs has no renderer and is not resolvable
objects.{object}._validations.{rule}a validation rule's name in that object's validations[] or in an objectExtensions[] entry aimed at it — a conditional rule's then / otherwise branch included
objects.{object}._actions.{action}an action bound to that object: one in its actions, or a stack-level action whose objectName is that object
globalActions.{action}an action with no objectName — a key naming a bound action is an error whose message names the objects.{owner}._actions.{action} key to write instead, {owner} being the action's own objectName
{action}.params.{param}a param's name on that action
{action}.params.{param}.options.{value}an option's stored value in that param's inline options — a warning, as on a field. A param that references a field and declares no inline options takes the field's list when the dialog renders, so its option keys are not judged
{action}.outcomeMessages.{outcome}an outcome the action's own outcomeMessages declares
{action}.resultDialog.fields.{path}the literal path of a field the action's resultDialog.fields[] declares — dots included, so client.secret is one key
{action}.resultDialog.title / .description / .acknowledgean action that declares a resultDialog, whether or not the dialog sets that text itself — for an action that declares none, nothing reads the dialog's copy
apps.{app}an app declared, or one a navigationContributions entry anywhere in the artifact contributes into — including an app owned outside the artifact
apps.{app}.navigation.{id}a navigation item id the app declares (nested children and areas[] included) or one contributed into it; under an app that is only contributed into, the contributed ids alone
dashboards.{dash} / .widgets.{id} / .actions.{actionUrl}a dashboard declared / a widget id on it / a header action's actionUrl on it
flows.{flow}a flow's machine name, declared
flows.{flow}.screens.{node}the id of a type: 'screen' node in that flow, nodes nested in loop, parallel and try/catch regions included
flows.{flow}.screens.{node}.fields.{field}a config.fields[].name on that screen — an object-form screen (config.objectName) declares none, and its input labels live under objects.{objectName}.fields

An unresolvable key is an error, not a warning: os validate, os lint and os compile each report it as translation-target-unknown and exit non-zero, so a bundle key naming a surface the metadata no longer declares fails the run (it was a warning until #16310). Nothing crashes — the string simply renders in its source locale while every neighbouring label resolves, so the app looks translated and one field does not — but that is what earns the error rather than excusing it: the orphan key stays a confident-looking grep hit, in every locale, for a surface that was deleted. The mis-keyed option below is the deliberate exception and stays a warning (translation-option-key-unknown): it names something real, so its remedy is a rename rather than a deletion.

The option case is worth spelling out. Option maps are keyed by the option's stored value, never by its display label:

// source: Field.select({ options: [{ value: 'direct_mail', label: 'Direct Mail' }] })

options: { direct_mail: '直邮' }    // ✅ keyed by value
options: { 'Direct Mail': '直邮' }  // ❌ keyed by the label — never resolves
options: { 'direct-mail': '直邮' }  // ❌ a variant spelling of the value

messages, settingsCommon and metadataForms are not checked: their keys are owned by application code, plugins, and the platform's own metadata-type registry rather than by this stack's metadata, so there is no set of legal names to resolve against. (settings is not checked either, and since it left the per-app bundle it cannot appear in one at all — the parse refuses it before any lint runs.)

Translation Service Integration

Design intent — not yet implemented. i18n.translationService is not a recognized key on TranslationConfigSchema (packages/spec/src/system/translation.zod.ts), and no auto-translate provider integration ships today. The snippet below describes planned behaviour — since #4001 the config shape is closed, so copying it into a real defineStack is a build-time rejection rather than a key silently dropped on the floor.

ObjectStack integrates with professional translation services:

// objectstack.config.ts
export default defineStack({
  i18n: {
    translationService: {
      provider: 'google-translate',
      apiKey: process.env.GOOGLE_TRANSLATE_API_KEY,
      
      // Auto-translate missing keys
      autoTranslate: true,
      
      // Target languages
      languages: ['de', 'es', 'fr', 'ja', 'zh-CN'],
    },
  },
});

Supported Services:

  • Google Cloud Translation
  • AWS Translate
  • DeepL
  • Microsoft Translator

Best Practices

1. Let the group structure be the organization

// ✓ GOOD: one object's translatable content under its own name
{
  "objects": {
    "account": { "label": "Account" },
    "contact": { "label": "Contact" },
    "opportunity": { "label": "Opportunity" }
  }
}

// ✗ BAD: a per-feature group invented at the top level — rejected by name
{
  "account": { "label": "Account" },
  "contact": { "label": "Contact" }
}

2. Provide context in message ids

Only messages ids are yours to name — every other key is derived from the metadata, so there is nothing to choose. Keep them single-segment and self-describing:

// ✓ GOOD: context clear, one segment
{ "messages": { "deleteButton": "Delete", "deleteConfirmMessage": "Are you sure?" } }

// ✗ BAD: ambiguous, and a dotted id `t()` can never walk to
{ "messages": { "delete": "Delete", "confirm.message": "Are you sure?" } }

3. Don't translate technical identifiers

An option map is keyed by the option's stored value and carries only the display text — the value itself never appears on the right-hand side:

// ✓ GOOD: labels translated, values are the keys
{
  "objects": {
    "account": {
      "fields": {
        "industry": {
          "options": { "technology": "Technologie", "finance": "Finanzen" }
        }
      }
    }
  }
}

// ✗ BAD: the stored value translated (breaks every query and filter)
{
  "objects": {
    "account": {
      "fields": { "industry": { "options": { "technologie": "Technologie" } } }
    }
  }
}

4. Test All Locales

// ✓ GOOD: Test with actual locales
describe('Account View', () => {
  it('should display in German', async () => {
    const context = createContext({ locale: 'de' });
    const view = await renderView('account_list', { context });
    expect(view.title).toBe('Konten');
  });
});

Configuration

// objectstack.config.ts
export default defineStack({
  i18n: {
    // Default locale
    defaultLocale: 'en',
    
    // Supported locales
    supportedLocales: ['en', 'de', 'es', 'fr', 'ja', 'zh-CN'],
    
    // Fallback chain
    fallbackLocale: 'en',

    // Translation service — design intent, not yet a recognized config key
    // translationService: {
    //   provider: 'google-translate',
    //   apiKey: process.env.GOOGLE_TRANSLATE_API_KEY,
    // },
  },
});

Summary

ObjectStack i18n provides:

  • Translation bundles in JSON format with object-first namespaces
  • Fallback-locale resolution for missing keys
  • {{param}} interpolation via t(key, locale, params)
  • Plugin integration for modular translation management
  • ObjectUI integration for automatic metadata-label translation
  • Translation management tools — os i18n extract and os i18n check

Planned (design intent, not yet implemented): locale-aware date/number/currency formatting helpers, CLDR pluralization, persisted user-locale switching, and translation-service auto-translate hooks.

Result: Build globally-ready applications without wrestling with i18n libraries.

On this page