Files
skills/docs/localization/i18n/react-i18n/src/translation-key/t-dynamic.md
2026-05-29 08:20:56 +01:00

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) 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

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.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<string, unknown> — 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<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'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 <Trans> instead so the branded TranslationKey gate fires.