Skip to main content
@craft-ts
LearnLearn with EffectAI agentsGuideReference
@craft-ts/core@craft-ts/component@craft-ts/dev-tools@craft-ts/effect@craft-ts/style@craft-ts/style-testing@craft-ts/i18n@craft-ts/i18n-effect@craft-ts/mcp
ExamplesMigrationEffect compatibility & maturityAdopting CraftTS progressivelyPress kitRoadmapBacklog
Beta
  • Learn
  • Learn with Effect
  • AI agents
  • Guide
  • Reference
  • Packages
  • Resources
  • Guide overview
  • Create a project
i.Core concepts
  • The mental model
  • Which primitive should I use?
  • Anatomy of a primitive
  • Generators and yield*
  • Insertions
  • Typed insertion pipes
  • Exceptions as values
ii.Managing state
  • Local state
  • State machines
  • query
  • Mutations
  • queryParams
  • asyncProcess
  • Selecting
  • Reacting to mutations
  • Collections
  • Collection utilities
  • Persistence
  • GlobalPersisterHandler
  • Pagination placeholders
  • Schema validation
iii.Recommended approaches
  • Inject at the point of use
iv.Structuring the app
  • craftService
  • Service scopes
  • Shaping the public API
  • Abstract services
  • App start
  • Lazy services
  • Server functions
  • craftRegisterFor
  • Target wrapper
v.Routing & type-safe DI
  • Setup
  • CLI automation
  • ESLint rules
  • Route providers
  • Route guards
  • Route exception handling
  • Non-blocking navigation
  • Global error component
  • Route load errors
  • Scaling routes
vi.Components & templates
  • Components
  • Activating the style system
  • Typed styles
  • Defining a design system
  • Tokens and variables
  • Global foundation and fonts
  • Pseudo-elements and animations
  • Axes and the matrix
  • Context obligations
  • Testing visual states
  • Text contrast
  • Visual attestation
  • Template obligations
  • Typed CSS variables
  • Fine-grained reactivity
  • Progressive rendering
  • Directives and .pipe(...)
  • settledValue & pendingNode
  • Accessibility
  • Customization
  • Content projection
  • Styling a component
  • Template migrator
vii.Forms
  • Overview
  • Validators
  • Submitting
  • Nested forms
  • Exception handling
  • Complete examples
viii.Testing
  • Testing services
  • Testing components
  • Type-level tests
  • Browser boundaries
  • Page stability
  • Architecture rules
    • Overview
    • Declarative baseline
    • Unique identities
    • HTTP endpoint ownership
    • Computed purity
    • Dependency cycles
    • Mutation reactions
    • Route DI proofs
    • Route component files
    • Path boundaries
    • Exclusive links
    • Persisted identities
    • insertSelect keys
    • craftEffect and network
    • craftEffect and imperative sync
    • Interactive element names
    • Metric thresholds
    • Server-state loaders
    • Primitive loader requirements
  • Extensible architecture graph
  • Graph insights
  • Craft graph vs Nx
ix.Reactivity utilities
  • craftComputed
  • craftEffect
  • craftMethod
  • source$
  • on$
  • fromEventToSource$
  • sourceFromEvent
  • afterRecomputation
x.Internationalisation
  • Type-safe i18n
  • The catalogue
  • Tokens
  • The runtime
  • With Effect
xi.Deployment (experimental)
  • Overview
  • Manifest reference
  • Diagnostics
  • Providers
  • Alchemy provider
xii.Going further
  • SSR and hydration
  • Effect integration
  • Program operators
  • Pattern matching
  • Observability
  • AI agents
  • Temporal runtime
  1. Internationalisation
  2. The runtime

The runtime#

createI18nRuntime turns a set of locales into the object the application translates through. It holds one active locale, and it is deliberately small: locale, setLocale, translate (aliased t), bind, loadLocale.

ts
import { createI18nRuntime } from '@craft-ts/i18n';

export const i18n = createI18nRuntime({
  locales,
  defaultLocale: 'en-US',
  // The time zone belongs here, once, rather than on every call site.
  timeZone: 'UTC',
});

strict defaults to on. At construction, every catalogue is validated and every locale is checked for parity against the first one — so a catalogue built in a way the types could not see still fails at startup rather than at the moment a user opens the page that needs it. Pass strict: false only when you have a reason you can write down.

timeZone belongs on the runtime, once. Putting it on each call site is how two dates in the same view end up in two zones.

Translating#

ts
i18n.t('order.total', { amount: 1234.5 }); // 'Order total €1,234.50.'
i18n.setLocale('fr-FR');
i18n.t('order.items', { count: 2 }); // '2 articles sont dans la commande.'

t is translate — the same function under two names, so a call site can read as t('order.total', …) without a local alias. The params argument is optional exactly when the message has no parameters, and required, with its exact shape, when it does.

setLocale(id) throws I18nRuntimeError with the code LOCALE_NOT_LOADED for a locale the runtime does not hold. So does t, if the active locale was somehow never loaded. The error is not a formatting failure to be swallowed: it means the app is about to render the wrong language.

The other codes it raises, all for the same reason — rendering something wrong is worse than not rendering:

codewhen
MISSING_PARAMa token's parameter is absent from the params object
INVALID_PARAMa guard rejected the value, or a schema's issues, quoted verbatim
ASYNC_SCHEMAa parameter's schema returned a promise; a message renders in sync
CRAFT_INJECTION_REQUIREDt met a token that resolves a service (see below)
INVALID_PLURAL_COUNTa plural selector that is not a finite number
UNKNOWN_KEYa key that no longer exists in the loaded catalogue

Reactive translation#

A string that does not change when the locale changes is not a translation. runtime.bind(dependency) returns a translator whose result re-reads whenever the dependency does — the dependency being an ordinary Craft reader, typically the state that holds the active locale:

ts
import { craftExpose, craftService, state } from '@craft-ts/core';

type Locale = 'en-US' | 'fr-FR';

// One service owns the active locale, and `bind` turns the runtime into a
// translator that re-reads whenever that state changes. Components consume the
// service; nothing builds a local binding.
export const { I18n } = craftService(
  { name: 'I18n', providedIn: 'global' },
  function* () {
    const runtime = createI18nRuntime({ locales, defaultLocale: 'en-US' });
    const language = yield* state('language', 'en-US' as Locale, ({ set }) => ({
      setLocale: function* (next: Locale) {
        runtime.setLocale(next);
        yield* set(next);
      },
    }));

    // Consumers use the scoped method at `I18n.language.setLocale`.
    yield* craftExpose('translate', runtime.bind(language));
  },
);

translate('order.items', { count }) then returns a generator the template yields like any other Craft reader. One service owns the locale for the whole app; components consume it rather than each building a local binding, which is what keeps two components from disagreeing about which language is on screen.

DI inside a translation#

Dependencies belong to the token that needs them, not to the whole i18n runtime:

ts
const orderAmount = money('amount', function* () {
  const currency = yield* ClientCurrency();
  return { currency: currency.code, minimumFractionDigits: 2 };
});

const catalog = defineCatalog({
  order: msg`Order total ${orderAmount}.`,
});

In a template, the translator's result is used exactly like any other child or attribute value — pass it, do not drive it:

ts
p(translate('order', { amount: 1234.5 }));
p({ title: translate('order', { amount: 1234.5 }) }, 'Order');

Both forms carry ClientCurrency into the component dependency contract, so the route check reports a missing provider at compile time, just as it does for a service the component yields.

The reader is a function, so yield* translate(...) does not type-check; and driving it yourself inside a template generator (yield* translate(...)()) hides the dependency from that check, exactly as a service yielded straight from a template does. Pass the reader.

t refuses such a message at compile time: its key type is StaticTranslationKey, the keys whose formatting resolves nothing. A resolver that yields no request is still a t key — the type and the runtime draw the same line.

Loading catalogues#

ts
import { createI18nLoader } from '@craft-ts/i18n';

// Caches by id, and — the part that matters — evicts a *failed* load, so a
// catalogue whose chunk died on a flaky network can be retried instead of
// staying permanently poisoned.
export const loader = createI18nLoader((id: string) =>
  import(`./locales/${id}.ts`).then((module) => module.locale),
);

export const runtime = createI18nRuntime({
  locales,
  defaultLocale: 'en-US',
  loader,
});

createI18nLoader caches by id and — the part that matters — evicts a failed load, so a catalogue whose chunk died on a flaky network can be retried instead of staying permanently poisoned. loadLocale(id) resolves once the catalogue is in; only then does setLocale accept it.

A locale must be listed to be named

setLocale and loadLocale are keyed on the ids in locales, so today a locale that is not in that array cannot be named without a cast — while a locale that is in it counts as already loaded and never reaches the loader. In practice that means the fully lazy catalogue is not expressible in the types yet. List every locale, and treat loader as the retry-safe cache in front of whatever your own loading code does.

A lazily obtained locale is not present at construction, so it is not covered by the startup parity check. Keep it covered by npm run i18n:check, which reads the files rather than the runtime.

Next#

  • With Effect — the same keys, as an Effect.

←TokensWith Effect→

On this page

  • Translating
  • Reactive translation
  • DI inside a translation
  • Loading catalogues
  • Next

@craft-ts · MIT licensed

GitHubnpm

Search