Tradurre
Tiny, type-safe, message-first i18n for React. No DSL, no ICU runtime, no codegen — translations are plain TypeScript functions.
Contents
Benefits
- Plain TS / JS — interpolation is template literals; every locale-scoped
Intltype (NumberFormat,DateTimeFormat,PluralRules,Collator,DisplayNames,DurationFormat,ListFormat,RelativeTimeFormat,Segmenter) is injected per formatter. - Type-safe arguments —
template<{ name: string }>({...})enforces the argument shape across every locale. - Message-first nesting — each message lives next to its translations.
- Full coverage enforced — every dictionary entry must define every configured locale; partial coverage is a compile-time error.
- Rich messages — formatters return
ReactNode, so JSX (links, styled spans, icons) embeds inline without a wrapper component. - RTL / LTR ready — every resolved bundle carries a full
Intl.Locale, sointl.locale.getTextInfo().directiongives you"ltr"or"rtl"for the active locale directly. - No runtime DSL — drop the
intl-messageformatparser entirely.
For runtimes without native Intl.PluralRules / Intl.NumberFormat / Intl.DateTimeFormat (older embedded webviews, some Hermes builds), Tradurre accepts a per-formatter polyfills map on new I18n({...}) — see the Intl polyfills recipe.
Getting started
pnpm add tradurre
Configure once in your app entry. The class returns a typed instance scoped to your locale list — no module-level globals. Every dictionary entry must define every locale in this list; a missing variant is a compile error.
import { I18n } from "tradurre";
enum Locale {
En,
Fr,
De,
}
export const i18n = new I18n({
locales: [Locale.En, Locale.Fr, Locale.De] as const,
});
Detect the active locale at boot and wrap your app in the provider. detect() reads navigator.languages (or navigator.language), matches each candidate's primary tag against the configured locales, and returns the first hit — falling back to locales[0] if nothing matches.
import { i18n } from "./i18n";
const detected = i18n.detect();
createRoot(document.getElementById("root")!).render(
<i18n.Provider locale={detected}>
<App />
</i18n.Provider>,
);
The locale prop on <i18n.Provider> is controlled — pass it to drive the active locale externally (from a router, a cookie, a user preference). Omit it and the provider manages locale state internally, starting at locales[0]. Consumers can switch the locale at any time:
function LanguageSwitcher() {
const { locale, setLocale } = i18n.useLocale();
return (
<select
value={locale}
onChange={(event) => {
const next = event.target.value;
if (i18n.isLocale(next)) setLocale(next);
}}
>
{i18n.locales.map((locale) => (
<option key={locale} value={locale}>
{locale}
</option>
))}
</select>
);
}
i18n.isLocale(value) is a type guard returning value is L, so next narrows to the locale union inside the branch — no casts needed.
RTL / LTR
Every useI18n(...) result carries the active locale as an Intl.Locale, and text direction comes from the standard getTextInfo() method. Wire it into your root element once and every RTL-aware layout falls out for free — Arabic, Hebrew, Persian, Urdu all flip, and every other locale stays LTR:
export function App() {
const intl = i18n.useI18n(translations);
useEffect(() => {
document.documentElement.dir = intl.locale.getTextInfo().direction;
document.documentElement.lang = intl.locale.baseName;
}, [intl.locale]);
return <YourApp />;
}
getTextInfo().direction is "ltr" or "rtl" — pass it into <html dir>, CSS-in-JS, or any UI kit that accepts a direction prop (Ant Design, MUI, Chakra, etc.). Because every dictionary entry defines every configured locale, intl.locale is always the active locale, so the resolved direction is always correct for the copy you're rendering. Everything else on the standard Intl.Locale API — region, script, numberingSystem, getWeekInfo(), getCalendars(), getHourCycles() — is reachable the same way.
Locale detection
detect() accepts an explicit list of BCP-47 candidates when the locale shouldn't come from navigator — useful for cookies, query strings, server-rendered headers, or a stored user preference taking precedence over the browser:
const detected = i18n.detect([
user?.preferences.locale,
cookies.get("locale"),
request.headers["accept-language"],
...navigator.languages,
]);
Candidates are tried in order. Non-strings are skipped, primary tags (fr-CA → fr) match before exact codes, and if nothing matches the function returns locales[0].
Writing messages
A dictionary is a flat record of message-id → entry. Every configured locale must be defined on every entry — partial coverage is a compile error.
Two entry kinds:
i18n.constant({...})— token-less. Consumed as a plain property:intl.copy.signIn.i18n.template<Args>({...})— takes typed tokens. Consumed as a call:intl.copy.greet({ name }).
Template formatters receive a single { tokens, format } payload — tokens is the typed args object you pass at the call site; format is locale-bound and exposes every Intl factory (number, dateTime, plural, list, relativeTime, displayNames, duration, collator, segmenter). Constant variants are plain ReactNode values by default, or ({ format }) => ReactNode when they need format access without tokens. See the Format factories recipe for a worked example of each.
import { i18n } from "./i18n";
namespace Tokens {
type Greet = { name: string };
}
export const translations = i18n.dictionary({
signIn: i18n.constant({
[Locale.En]: "Sign in",
[Locale.Fr]: "Se connecter",
[Locale.De]: "Anmelden",
}),
greet: i18n.template<Tokens.Greet>({
[Locale.En]({ tokens }) {
return `Hello, ${tokens.name}`;
},
[Locale.Fr]({ tokens }) {
return `Bonjour, ${tokens.name}`;
},
[Locale.De]({ tokens }) {
return `Hallo, ${tokens.name}`;
},
}),
});
Constants and templates are plain values, so hoist common copy — button labels (Save, Cancel, Submit), validation messages, shared microcopy — into their own modules and reuse them across dictionaries. Type inference flows across the import boundary, so intl.copy.greet({ name }) stays fully typed. Let TypeScript infer the return; don't widen the export to Entry<L> or the Args generic gets erased.
export const signIn = i18n.constant({
[Locale.En]: "Sign in",
[Locale.Fr]: "Se connecter",
[Locale.De]: "Anmelden",
});
export const greet = i18n.template<Tokens.Greet>({
[Locale.En]({ tokens }) { return `Hello, ${tokens.name}`; },
[Locale.Fr]({ tokens }) { return `Bonjour, ${tokens.name}`; },
[Locale.De]({ tokens }) { return `Hallo, ${tokens.name}`; },
});
export const translations = i18n.dictionary({ signIn, greet });
Usage
import { i18n } from "./i18n";
import { translations } from "./translations";
type WelcomeProps = {
name: string;
};
export function Welcome({ name }: WelcomeProps) {
const intl = i18n.useI18n(translations);
return (
<section>
<h1>{intl.copy.greet({ name })}</h1>
<p>{intl.copy.signIn}</p>
</section>
);
}
useI18n(...) returns { copy, locale }. copy is the fully resolved dictionary — constants land as plain ReactNode properties (intl.copy.signIn); templates land as typed callables (intl.copy.greet({ name })). locale is the active Intl.Locale; reach direction via intl.locale.getTextInfo().direction and every other locale-specific bit via the standard Intl.Locale API. format inside formatters is bound automatically to the active locale.
Components
Template formatters return ReactNode, so you can return JSX directly — wrap a count in a styled element, drop a <Link> inline, whatever. There is no dedicated <Trans> component because there is nothing to wrap: a message is a (args) => ReactNode function, so you call it in JSX:
namespace Tokens {
type Articles = { count: number };
}
export const translations = i18n.dictionary({
articles: i18n.template<Tokens.Articles>({
[Locale.En]({ tokens, format }) {
const category = format.plural().select(tokens.count);
return category === "one" ? (
<P>{tokens.count} article</P>
) : (
<P>{tokens.count} articles</P>
);
},
[Locale.Fr]({ tokens, format }) {
const category = format.plural().select(tokens.count);
return category === "one" ? (
<P>{tokens.count} article</P>
) : (
<P>{tokens.count} articles</P>
);
},
}),
});
type ArticleCountProps = {
count: number;
};
function ArticleCount({ count }: ArticleCountProps) {
const intl = i18n.useI18n(translations);
return <>{intl.copy.articles({ count })}</>;
}
String returns inline as text, JSX returns render their tree. The arg type is inferred from the message, so passing the wrong shape is a compile error.
Testing
i18n.withI18n(locale, element) wraps any React element in the provider, bound to the given locale. It returns a ReactElement you can pass straight to your renderer of choice — no wrapper boilerplate, no separate <Provider> import in every test file:
import { render, screen } from "@testing-library/react";
import { i18n } from "./i18n";
import { Welcome } from "./Welcome";
it("greets in French", () => {
render(i18n.withI18n(Locale.Fr, <Welcome name="Imogen" />));
expect(screen.getByRole("heading")).toHaveTextContent("Bonjour, Imogen");
});
it("greets in German", () => {
render(i18n.withI18n(Locale.De, <Welcome name="Imogen" />));
expect(screen.getByRole("heading")).toHaveTextContent("Hallo, Imogen");
});
locale is typed against your configured locale union, so passing an unsupported locale is a compile error. The helper is just createElement(this.Provider, { locale }, element) under the hood — no dependency on @testing-library/react, so it composes with any React renderer (RTL, react-test-renderer, Ink, etc.).