6.7 KiB
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)withcontext: { key, locale }. - Always-warn observability — every successful AND missing call emits one
warn-level log entry. Dashboards filter on theoutcomefield of the log context ("hit"or"miss") to track drift back to dynamic-key patterns. - Optional interpolation — pass
valuesthrough the same options bag the universal core'st()accepts. - Injectable sink — production wires
@suaveplan/loggervia thesinkoption; the default falls back to aconsole.warnindirection 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'sI18nInstancejust enough to drive the lookup), so tests can drive it with a hand-rolled fake.
Usage: Basic Example
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 <div role="alert">{message}</div>;
}
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:
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.requiredresolves to"{field} is required"—fieldinterpolates via the universal core's interpolation engine. The sink fires once with{ key: "validation.required", locale, outcome: "hit" }. - Miss:
validation.unknown_thingis absent — the sink fires once with{ key: "validation.unknown_thing", locale, outcome: "miss" }, thentDynamicthrowsReactI18nError(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 exposelocale: string,catalog.has(key): boolean, andt(key, opts?): string. RealI18nInstancefrom@suaveplan/i18nsatisfies this structurally.key: string— runtime-derived translation key.options.values?: Record<string, unknown>— optional interpolation values.options.sink?: (message, context?) => void— optional warn-log sink (defaults to aconsole.warnindirection).
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<string, unknown>
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<string, unknown>) => 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'sMessageCatalogexposes this directly. The helper short-circuits BEFORE callingt()so the warn-then-throw ordering on the miss path is deterministic (sink fires, then the throw happens in the same tick). - The thrown
ReactI18nErrorcarriescontext: { 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
invokeConsoleWarnindirection 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.hascheck + onet()call + one sink invocation. The sink invocation is the intentional cost — it's the observability surface, not overhead. - Limitation:
tDynamiccannot enforce that the key was intended to be dynamic. Reviewers should reject any PR that introduces atDynamic(i18n, "literal.string")call — that case must useuseTranslation()or<Trans>instead so the brandedTranslationKeygate fires.