# `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.