Catalogs & hooks

Type-safe message catalogs with defineCatalog + defineLocale (parity-enforced), reading translations with useT / <T> / useLocale, ICU placeholders, code-splitting with defineCatalogs + LazyI18nProvider, and the react-intl escape hatch.

A catalog is a flat Record<string, string> of message ID → ICU MessageFormat template. Voltro picks flat-string format (instead of react-intl's { defaultMessage, description } objects) because translation tools (Crowdin / Lokalise / Phrase) import flat string maps natively, the format diffs cleanly in code review, and the base catalog is the source of truth — defaultMessage becomes redundant.

Catalog files — type-safe convention

// src/locales/en.ts — base catalog, source of truth
import { defineCatalog } from '@voltro/i18n'

export default defineCatalog({
  'header.cta':     'Get started',
  'home.greeting':  'Hello, {name}',
  'errors.network': 'Connection lost. Retry?',
} as const)
// src/locales/de.ts — MUST mirror en exactly
import { defineLocale } from '@voltro/i18n'
import en from './en'

export default defineLocale<typeof en>()({
  'header.cta':     'Loslegen',
  'home.greeting':  'Hallo, {name}',
  'errors.network': 'Verbindung verloren. Erneut versuchen?',
})

defineCatalog is an identity function that pins the catalog's literal-key type. defineLocale<typeof en>() returns a curried function whose argument must mirror the base catalog's keys exactly — missing or extra keys fail at tsc --noEmit, not at runtime. This is what prevents "translation drift", where non-base locales silently fall out of sync as the base catalog grows.

This parity check is the whole point of having a base catalog. Don't skip defineLocale<typeof en>() for non-base catalogs — plain defineCatalog(...) works but loses the type-level enforcement.

ICU placeholders

Keep ICU MessageFormat placeholders identical across localesreact-intl validates them at render time. That covers simple interpolation ({name}) and the full ICU surface — plurals, select, ordinals:

// en.ts
export default defineCatalog({
  'cart.items': '{count, plural, one {# item} other {# items}}',
} as const)
// de.ts
export default defineLocale<typeof en>()({
  'cart.items': '{count, plural, one {# Artikel} other {# Artikel}}',
})

Don't roll your own ICU MessageFormat. Pluralization, gender, and ordinals across 50+ languages is a solved problem; reimplementing it produces bugs that only surface in specific locales (Turkish dotted-i, Arabic plurals, Finnish cases).

Check placeholder parity, not just key parity. defineLocale enforces that every locale has the same KEYS, but not that each message has the same {var} set — a translation that drops or renames a {var} compiles and boots, then throws The intl string context variable "date" was not provided only in that locale, only when the message renders. assertCatalogParity catches it up front:

import { assertCatalogParity } from '@voltro/i18n'
import en from './locales/en'
import de from './locales/de'

assertCatalogParity({ en, de }) // throws, listing every {var} drift

Call it in a test (or at boot with { onMismatch: 'warn' }). Plural argument names are compared; a plural's inner {# item} branches are not mistaken for placeholders.

Reading translations in components

import { useT, useTFn, T, useLocale } from '@voltro/i18n'

// JSX form — preserves rich-text capabilities (component injection).
<T id="home.greeting" values={{ name: 'Mario' }} />

// Imperative form — for non-JSX contexts (aria-label, placeholder,
// document.title, toast). Returns a plain string.
const cta = useT('header.cta')
const greeting = useT('home.greeting', { name: 'Mario' })

// Function form — capture once, replay inside helpers / list maps /
// conditional branches without N useIntl() calls per render.
const t = useTFn()
const labels = items.map((i) => t('cart.items', { count: i.count }))

// Active locale code (for switching CSS rules, locale-aware date
// pickers, etc.).
const locale = useLocale()
  • <T id="…" /> — JSX form. Use this whenever you're rendering a string into the tree; it preserves rich-text (React-element values).
  • useT(id, values?) — imperative form returning a plain string, for non-JSX call sites: placeholder, aria-label, document.title, toasts, error messages.
  • useTFn() — returns a (id, values?) => string translator the component captures once. Use when one component needs t inside helper functions or .map() callbacks — react-intl runs once, the closure replays.
  • useLocale() — the active locale code, as React state inside the provider.
  • useMessages() — the active locale's RAW catalog (useMessages()['some.id']): the unformatted ICU template, not the formatted output. For when you need the raw string — e.g. to feed your own formatter, or a lookup that must not run ICU.

Compile-time keys AND params — createTypedMessages

defineLocale catches a missing KEY at compile time, but the bare useT / useTFn don't type the KEY or the PARAMS at the call site — so useT('home.greeting') (a message that needs {name}) compiles and then throws at render (The intl string context variable "name" was not provided). createTypedMessages<typeof en>() binds the catalog's literal message types to useT / useTFn / <T> so both are compile errors:

// src/i18n.ts — call once with your base catalog, re-export the result
import { createTypedMessages } from '@voltro/i18n'
import type en from './locales/en'

export const { useT, useTFn, T } = createTypedMessages<typeof en>()
// now, everywhere you import from './i18n' instead of '@voltro/i18n':
useT('home.greeting')              // ✗ compile error — expected { name: … }
useT('home.greeting', { name })    // ✓
useT('header.search')              // ✓ no placeholders → no values arg
useT('does.not.exist')             // ✗ compile error — not a catalog key

It's almost pure type refinement — the runtime is the same useT / useTFn / <T> (plus a .dynamic escape, below), only the signatures narrow to your catalog. Requires the base catalog to be as const (so its message strings survive as literal types).

Scope. Simple {name} and single-argument {count, number} placeholders are extracted and required. A nested inline ICU message ({count, plural, one {…} other {…}} / select) is fully parsed: the top-level arg (count) AND a real var nested inside a branch are both required, while a branch's literal text is never mistaken for a var. So for '{count, plural, one {# blocker in {discipline}} other {# blockers in {discipline}}}', t('blockers', { count, discipline }) is required — omit discipline and it's a compile error, not a render-time throw:

t('blockers', { count: n, discipline })   // ✓ both required — discipline lives inside the branches

<T> gets a typed key with loose values, because its rich-text <tag> renderers can't be modelled by placeholder extraction.

Runtime-computed keys — t.dynamic

For a key you build at runtime, do NOT cast it to the catalog key union — that union spans placeholder-bearing keys, so the call then demands a spurious 2nd ICU arg. Use t.dynamic, a plain-string escape with no forced args, on both useT and the useTFn() result:

import { useTFn } from '@app/messages' // your createTypedMessages() barrel — `.dynamic` lives on these
const t = useTFn()
t.dynamic(`status.${row.state}`)                 // ✓ plain string, no forced arg
t.dynamic(`greeting.${kind}`, { name })          // values still allowed, but loose

Passing t across a package boundary — LooseTFunction

Type a t pass-through param as TypedTFunction<C> to keep it strict — the strict type propagates down through helper signatures. At a boundary that can't import your catalog (a shared UI package), type the param LooseTFunction ((id: string, values?) => string) instead of (...args: any[]) => string, and pass t.dynamic — which IS a LooseTFunction. A strict TypedTFunction is deliberately NOT assignable to the loose one (a narrowed key param can't satisfy a wider one, and silently allowing it would erase the checking).

// a shared package that can't see the app catalog
import type { LooseTFunction } from '@voltro/i18n'
export const formatError = (t: LooseTFunction, e: AppError): string => t(e.messageKey)

// in the app, at the boundary:
formatError(t.dynamic, err)

A convenient app-wide barrel captures the bound types once:

// src/i18n.ts
import { createTypedMessages, type TypedTFunction } from '@voltro/i18n'
import type en from './locales/en'

export const { useT, useTFn, T } = createTypedMessages<typeof en>()
export type AppTFunction = TypedTFunction<typeof en>        // for helper / prop params
export type AppMessageKey = Parameters<AppTFunction>[0]     // the catalog's key union

Reading a message outside React — meta({ locale })

useT is a hook — it only runs inside a component, against the active locale. A page's meta({ locale }) runs OUTSIDE React (at build / SSR meta-resolution time) and is handed an arbitrary locale string. Use pickCatalog to resolve a message there:

// src/locales/index.ts
import { pickCatalog } from '@voltro/i18n'
import en from './en'
import de from './de'

export const getCatalog = (locale?: string) => pickCatalog({ en, de }, locale, 'en')
// any page — meta gets the active locale (from the URL prefix, or the voltro:lang cookie)
import { getCatalog } from '../locales'

export const meta = ({ locale }: { readonly locale: string }): PageMeta => ({
  title: getCatalog(locale)['meta.home.title'],
})

pickCatalog(catalogs, locale, defaultLocale) returns the concrete catalog type, so a known-key lookup is string (not string | undefined) — exactly what PageMeta.title needs. An unknown or undefined locale falls back to defaultLocale.

Code-splitting catalogs — defineCatalogs

Why pickCatalog cannot split

pickCatalog({ en, de, fr }, locale) is a static import map. Every catalog is a value-level import of the module that builds the map, so the bundler has no choice but to put all of them in one chunk — a visitor who will only ever see German downloads English and French too. At real catalog sizes that becomes the single largest client chunk in the app, and it grows linearly with every locale you add.

No provider-side change can fix this. The cost is paid at import time, before any React code runs — by the time a provider knows which locale is active, all of them are already in the bundle.

The only thing a bundler treats as a chunk boundary is a dynamic import(). So the catalog map becomes a map of loaders:

// src/locales/index.ts
import { defineCatalogs } from '@voltro/i18n'

export const catalogs = defineCatalogs({
  en: () => import('./en'),
  de: () => import('./de'),
}, 'en')

Each arrow is its own chunk; a session fetches exactly the locales it uses. Loaders accept either an export default catalog or a bare map, so the catalog files from the top of this page work unchanged.

defineCatalogs(loaders, defaultLocale) returns:

  • locales — the declared locales, in declaration order.
  • defaultLocale — the base locale. Its type is constrained to the loader keys, so a defaultLocale you never declared a loader for is a compile error, not a runtime blank page.
  • load(locale) — resolves the catalog, importing its chunk on first use. Concurrent callers share one import: two components mounting in the same tick will not race two fetches.
  • peek(locale) — the catalog if already loaded, else undefined. Never triggers a fetch. This is what lets a provider render synchronously.
  • preload(locale) — fire-and-forget cache warming (server boot, hover intent, a route transition that is about to switch locale).

An unknown locale resolves to defaultLocale rather than throwing — a stale cookie or a hand-typed URL prefix degrades to the base language instead of blanking the app. A failed load is not cached, so the next attempt retries: a 404'd chunk on a flaky network is recoverable.

<LazyI18nProvider>

import { LazyI18nProvider } from '@voltro/i18n'
import { catalogs } from './locales'

<LazyI18nProvider catalogs={catalogs} locale={locale} fallback={<AppSkeleton />}>
  <App />
</LazyI18nProvider>

A resolved catalog is cached per loader map, so switching back to a locale is synchronous and re-renders never re-import.

The trade-off, stated honestly

The active catalog is now asynchronous, and first paint needs it resolved. There are two ways to keep that from becoming a flash of untranslated UI, and you should pick one deliberately:

  • catalogs.preload(locale) on the server, or before hydrateRoot. The catalog is already in the cache, peek() hits, and the provider renders in the same tick as a static catalog would — no suspense boundary, no flash. This is the right default, and it costs no waterfall: the locale is known from the cookie or the URL prefix before React starts.
  • fallback. Rendered for the one tick it takes to load a catalog that genuinely isn't in memory. It defaults to null — deliberately blank rather than a screen of untranslated message IDs.

So: fallback is for a locale SWITCH, not for first paint. On a switch the user already has a rendered page and a brief placeholder is fine. If your fallback is showing on first load, the preload is missing — fix the preload, don't dress up the fallback.

meta({ locale }) is synchronous and runs outside React, so it still needs the static pickCatalog form. Keep that map in a module the client bundle doesn't import, or the static graph pulls every locale back into the browser chunk and undoes the split.

The react-intl escape hatch

For features the wrap doesn't expose — custom formatters, Intl options, rich-text with React-element values — import from react-intl directly:

import { useIntl, FormattedMessage, FormattedNumber, FormattedDate } from 'react-intl'

The wrap deliberately doesn't re-export everything, to keep the framework's blessed API small. The library is already in your node_modules.

Anti-patterns

  • Don't bake locale-specific strings into shadcn primitives, hooks, or anything in packages/*. Strings live in the consuming app's src/locales/. Framework and kit code stay string-free.
  • Don't read the cookie directly in components. Use useT / useLocale — the active locale is React state, not browser state, inside the provider.
  • Don't use translated strings as React keys or as switch discriminators. Use IDs / enums for control flow; translations are presentation only.
  • Don't translate code samples in markdown. Comments inside code fences stay in the source language — readers copy code as-is, and translated identifiers don't compile.