# `tDynamic` — runtime escape hatch for dynamic translation keys
## Purpose
`tDynamic(i18n, key, values?)` is the rare-use escape hatch for translation lookups whose key cannot be statically known —
the canonical example is mapping an error code to its localised message:
``tDynamic(i18n, `errors.${error.code.toLowerCase()}`)``.
The branded `TranslationKey` contract enforces a closed set of keys at compile time; `tDynamic` is the one supported way to
bypass that contract while still keeping the runtime contract loud and observable.
The function is a plain (non-React) helper so it works in any context — hooks, event handlers, stores, server-side. Inside a
React component, callers obtain `i18n` from `useI18n()` (sub-wave 1c). Outside React, callers thread the instance manually.
Either way, every call emits a `warn`-level log entry and a missing key throws `ReactI18nError(REACT_I18N.UNKNOWN_KEY)` so
no raw key can ever leak into rendered output.
## Features
- **Hard runtime contract** — missing keys throw `ReactI18nError(REACT_I18N.UNKNOWN_KEY)` with `context: { key, locale }`.
- **Always-warn observability** — every successful AND missing call emits one `warn`-level log entry. Dashboards filter on
the `outcome` field of the log context (`"hit"` or `"miss"`) to track drift back to dynamic-key patterns.
- **Optional interpolation** — pass `values` through the same options bag the universal core's `t()` accepts.
- **Injectable sink** — production wires `@suaveplan/logger` via the `sink` option; the default falls back to a
`console.warn` indirection so the helper degrades gracefully when no logger peer is available.
- **Structural typing** — accepts any object matching `TDynamicI18nLike` (mirrors the public surface of
`@suaveplan/i18n`'s `I18nInstance` just enough to drive the lookup), so tests can drive it with a hand-rolled fake.
## Usage: Basic Example
```ts
import { tDynamic } from "@suaveplan/react-i18n";
import { useI18n } from "@suaveplan/react-i18n";
function ErrorBanner({ error }: { error: SuaveplanError }) {
const { i18n } = useI18n();
// Resolves the catalog key `errors.auth.invalid_token` for an
// error whose code is `AUTH.INVALID_TOKEN`.
const message = tDynamic(
i18n,
`errors.${error.code.toLowerCase().replace(".", "_")}`
);
return
{message}
;
}
```
If the catalog has no entry under `errors.auth.invalid_token`, the call throws
`ReactI18nError(REACT_I18N.UNKNOWN_KEY)` with `context.key === "errors.auth.invalid_token"` and `context.locale ===
i18n.locale`. The nearest error boundary catches the throw — `tDynamic` never silently returns a key string to the user.
## Usage: Advanced Example — wired logger sink + interpolation
In production, swap the default `console.warn` sink for the package's wired `@suaveplan/logger` warn channel and pass
interpolation values:
```ts
import type { LoggerLike } from "@suaveplan/types";
import { tDynamic } from "@suaveplan/react-i18n";
function localiseValidation(
i18n: I18nInstance,
logger: LoggerLike,
code: string,
fieldName: string
): string {
return tDynamic(i18n, `validation.${code}`, {
values: { field: fieldName },
sink: (message, context) => {
logger.warn(message, context);
},
});
}
```
Two call paths:
- **Hit:** `validation.required` resolves to `"{field} is required"` — `field` interpolates via the universal core's
interpolation engine. The sink fires once with `{ key: "validation.required", locale, outcome: "hit" }`.
- **Miss:** `validation.unknown_thing` is absent — the sink fires once with `{ key: "validation.unknown_thing", locale,
outcome: "miss" }`, then `tDynamic` throws `ReactI18nError(REACT_I18N.UNKNOWN_KEY)` so the caller's error boundary takes
over.
The `sink` is the testability seam: tests pass a `(msg, ctx) => calls.push([msg, ctx])` array-pusher and assert on the
captured calls without touching the global `console`.
## API Reference
### `tDynamic(i18n, key, options?) → string`
Parameters:
- `i18n: TDynamicI18nLike` — active i18n instance. Must expose `locale: string`, `catalog.has(key): boolean`, and
`t(key, opts?): string`. Real `I18nInstance` from `@suaveplan/i18n` satisfies this structurally.
- `key: string` — runtime-derived translation key.
- `options.values?: Record` — optional interpolation values.
- `options.sink?: (message, context?) => void` — optional warn-log sink (defaults to a `console.warn` indirection).
Returns: the translated string.
Throws: `ReactI18nError(REACT_I18N.UNKNOWN_KEY)` with `context: { key, locale }` when `key` is absent from the active catalog.
Side-effect: every call (hit OR miss) invokes `sink` exactly once with `(WARN_MESSAGE, { key, locale, outcome })` where
`outcome` is `"hit"` or `"miss"`.
### `interface TDynamicI18nLike`
Structural shape required of the `i18n` argument: `locale: string`, `catalog: { has(key): boolean }`, and `t(key, opts?):
string`. Mirrors the public surface of `@suaveplan/i18n`'s `I18nInstance` minimally so the helper stays decoupled from
implementation details.
### `type TranslationValues = Record`
Convenience alias for the interpolation values bag. Mirrors `TranslateOptions.values` from `@suaveplan/i18n`.
### `interface TDynamicOptions`
`{ values?: TranslationValues; sink?: TDynamicWarnSink }`. Both optional.
### `type TDynamicWarnSink = (message: string, context?: Record) => void`
Per-call warn-log sink. The default sink is a `console.warn` indirection that no-ops when no `console` is available.
## Implementation Notes
- The presence check uses `i18n.catalog.has(key)` — the universal core's `MessageCatalog` exposes this directly. The helper
short-circuits BEFORE calling `t()` so the warn-then-throw ordering on the miss path is deterministic (sink fires, then
the throw happens in the same tick).
- The thrown `ReactI18nError` carries `context: { key, locale }` so observers can correlate the failure with the offending
call site without parsing the error message.
- The default sink is wrapped in a tiny `invokeConsoleWarn` indirection so the call site stays free of any
static-analysis suppression directive (CLAUDE.md "Critical" rule 9). The indirection is named so call-graph audits can
grep for the single sink call across the package.
- Performance: the hot path is one `Map.has` check + one `t()` call + one sink invocation. The sink invocation is the
intentional cost — it's the observability surface, not overhead.
- Limitation: `tDynamic` cannot enforce that the key was *intended* to be dynamic. Reviewers should reject any PR that
introduces a `tDynamic(i18n, "literal.string")` call — that case must use `useTranslation()` or `` instead so the
branded `TranslationKey` gate fires.