@yaebal/i18n
typed translations: ctx.t with compile-time keys and params, nested dicts, Intl.PluralRules plurals for any language, first-contact locale detection from
telegram's language_code, and per-chat persistence through any StorageAdapter. powers useTranslation() in the morda jsx layer.
install
pnpm add @yaebal/i18nusage
declare dicts as const, pass them to i18n() and install it with .install(). the plugin resolves the locale for the current chat before your handlers
run, so ctx.t is ready immediately — typed from the default locale's dict.
import { Bot } from "yaebal";
import { type Dict, i18n, type LocaleLike } from "@yaebal/i18n";
// `as const` keeps the templates as literal types, so ctx.t gets
// typed keys AND typed params — a typo is a compile error
const en = {
welcome: "hello {name}!",
bye: "goodbye",
menu: { title: "menu" }, // nested → t("menu.title")
} as const satisfies Dict;
// LocaleLike keeps ru structurally in sync with en. every key is
// optional — missing keys fall back to the default locale.
const ru = {
welcome: "привет {name}!",
menu: { title: "меню" },
} as const satisfies LocaleLike<typeof en>;
const bot = new Bot(process.env.BOT_TOKEN!)
.install(i18n({ defaultLocale: "en", locales: { en, ru } }));
bot.command("start", async (ctx) => {
await ctx.reply(ctx.t("welcome", { name: ctx.from!.first_name }));
});
bot.command("lang", async (ctx) => {
// typed: only "en" | "ru" compile; unknown locales throw TypeError at runtime
await ctx.changeLanguage(ctx.locale === "ru" ? "en" : "ru");
await ctx.reply(ctx.t("welcome", { name: ctx.from!.first_name }));
});
bot.start();import { createBot, i18n } from "yaebal";
const bot = createBot(process.env.BOT_TOKEN!)
.install(i18n({
defaultLocale: "en",
// inline dicts keep their literal types, so ctx.t gets typed keys;
// the first locale a user sees is detected from language_code
locales: {
en: { hello: "hello", switched: "language: english" },
es: { hello: "hola", switched: "language: spanish" },
},
}));
bot.command("hello", (ctx) => ctx.reply(ctx.t("hello")));
bot.command("lang", async (ctx) => {
await ctx.changeLanguage(ctx.locale === "en" ? "es" : "en");
await ctx.reply(ctx.t("switched"));
});
bot.start();typed keys and params
the default locale's dict defines the whole typed surface: keys (nested ones as "parent.child"), the exact param names of every {placeholder} template, and the return type of every value. with a plain
(widened) dict everything degrades gracefully to (key, params?) => string.
ctx.t("welcome", { name: "sam" }); // ✓
ctx.t("welcom", { name: "sam" }); // ✗ compile error: unknown key
ctx.t("welcome"); // ✗ compile error: missing { name }
ctx.t("welcome", { nom: "sam" }); // ✗ compile error: wrong param name
ctx.t("menu.title"); // ✓ nested keys are dotted
ctx.changeLanguage("de"); // ✗ compile error: not a configured localelocale detection
the first time a user talks to the bot there is nothing in storage yet — the plugin then matches
telegram's language_code against the configured locales, so users get their language
before ever touching a language switcher. a locale pinned with changeLanguage always wins over detection.
// resolution order per update:
// 1. stored locale (set by changeLanguage, persisted per chat)
// 2. detected locale — ctx.from?.language_code, matched exactly,
// then by base language ("pt-BR" → "pt")
// 3. defaultLocale
//
// so a russian user gets russian on the very first /start.
bot.install(i18n({
defaultLocale: "en",
locales: { en, ru },
// override detection (or disable it with () => undefined)
detectLocale: (ctx) => ctx.from?.language_code,
}));api
| export | signature | description |
|---|---|---|
i18n | (options) | (instance, options?) => Plugin<Context, I18nControls> | the plugin — build from options, or wire a shared createI18n instance |
createI18n | (options: CreateI18nOptions) => I18n | standalone translator: t(locale, key, params?), locales, has, resolveLocale — for code outside middleware |
I18nControls | interface | what the plugin adds to the context (t, locale, locales, defaultLocale, changeLanguage) |
Dict | interface | a locale's translation table: templates, plural sets, functions, nested tables |
DictValue | string | PluralDict | DictFn | one translation: a template, plural forms, or a function of typed params |
PluralDict | { zero?, one?, two?, few?, many? : string; other: string } | plural forms keyed by Intl.PluralRules categories; other is required |
LocaleLike<Base> | type | structural check for non-default locales: same shape, every key optional |
TFn<D> / DictKeys<D> | types | the typed translate function and its key union — exported for wrappers |
options
| field | type | required | description |
|---|---|---|---|
defaultLocale | keyof locales | yes | the fallback locale; its dict defines the typed key set |
locales | Record<string, Dict> | yes | all translation dictionaries keyed by locale code |
storage | StorageAdapter<string> | no | where to persist each chat's locale. defaults to MemoryStorage |
getKey | (ctx) => string | undefined | no | storage key for the update. default: chat id, falling back to user id |
detectLocale | (ctx) => string | undefined | no | first-contact detection, used only when nothing is stored. default: ctx.from?.language_code |
onMissingKey | (key, locale) => string | undefined | no | hook for keys missing in every locale; return a replacement or fall back to the key |
I18nControls (added to ctx)
| property | type | description |
|---|---|---|
t | TFn<D> | translate a key of the default locale's dict; params and return type are inferred |
locale | keyof locales | the active locale for this update (reflects changeLanguage immediately) |
locales | readonly (keyof locales)[] | every configured locale — handy for building a /lang menu |
defaultLocale | keyof locales | the configured fallback locale |
changeLanguage | (locale) => Promise<void> | switch and persist the locale; throws TypeError on unknown locales, so
garbage can never be persisted |
persistent storage
the default MemoryStorage is lost on restart. pass any StorageAdapter<string> (the @yaebal/sklad contract) to
persist locales across restarts. a stored value that no longer matches a configured locale is
ignored instead of poisoning the chat.
import { i18n } from "@yaebal/i18n";
import { type StorageAdapter } from "@yaebal/sklad";
// any StorageAdapter<string> keeps locale across restarts
class RedisLocaleStorage implements StorageAdapter<string> {
async get(key: string): Promise<string | undefined> { /* ... */ }
async set(key: string, value: string): Promise<void> { /* ... */ }
async delete(key: string): Promise<void> { /* ... */ }
}
bot.install(i18n({
defaultLocale: "en",
locales: { en, ru },
storage: new RedisLocaleStorage(),
}));per-user locale
override getKey to partition by user instead of chat.
bot.install(i18n({
defaultLocale: "en",
locales: { en, ru },
// partition by user instead of the default chat-then-user key
getKey: (ctx) => ctx.from?.id?.toString(),
}));pluralization
a translation value can be a PluralDict instead of a string — an object keyed by Intl.PluralRules categories. pass the count as the n param; the
category is chosen with the rules of the locale that supplied the forms, so a fallback
string keeps its own language's grammar. a plural value without a numeric n is a
compile error from typed code and a TypeError from untyped code.
const en = {
// a plural value is an object keyed by Intl.PluralRules categories.
// "other" is required; the rest are optional per locale.
apples: { one: "{n} apple", other: "{n} apples" },
} as const satisfies Dict;
const ru = {
apples: { one: "{n} яблоко", few: "{n} яблока", many: "{n} яблок", other: "{n} яблока" },
} as const satisfies LocaleLike<typeof en>;
bot.command("count", async (ctx) => {
// the numeric n param is required — by the compiler and at runtime
await ctx.reply(ctx.t("apples", { n: 1 })); // en → "1 apple"
await ctx.reply(ctx.t("apples", { n: 5 })); // ru → "5 яблок"
});
// fallback keeps the right grammar: if ru lacks a key and the en forms are
// used, the category is chosen with en rules — never "21 apple".formatted translations
a dict value can be a function of typed params. return an html/md/format result from @yaebal/fmt or core and the translation carries entities — the
function's return type flows through ctx.t.
import { html } from "@yaebal/fmt";
const en = {
// a dict value can be a function: typed params in, anything out.
// return html`...`/md`...`/format`...` and the translation carries
// MessageEntity objects — no parse_mode, nothing to escape.
hello: (p: { name: string }) => html`<b>hello</b>, ${p.name}!`,
} as const satisfies Dict;
// the return type flows through t; reply accepts { text, entities } as-is
bot.command("start", (ctx) => ctx.reply(ctx.t("hello", { name: ctx.who })));outside middleware
createI18n builds the translator with no bot wiring — for broadcasts, scheduled jobs, or per-locale command menus —
and the plugin can share the same instance.
import { createI18n, i18n } from "@yaebal/i18n";
// a pure translator — no bot wiring, usable in broadcasts, jobs, menus
const strings = createI18n({ defaultLocale: "en", locales: { en, ru } });
strings.t("ru", "welcome", { name: "Юра" }); // → "привет Юра!"
strings.t("no-such-locale", "bye"); // → "goodbye" (default fallback)
strings.resolveLocale("pt-BR"); // → configured locale or undefined
strings.locales; // → ["en", "ru"]
// the plugin can share the same instance
bot.install(i18n(strings, { storage: new RedisLocaleStorage() }));fallback behaviour
// "ru" locale has no "bye" key → falls back to "en"
// "en" has no "missing" key → returns the key itself (or onMissingKey's result)
ctx.t("bye"); // → "goodbye" (en fallback)
ctx.t("missing"); // → "missing" (key fallback — untyped code only; typed keys
// make this unrepresentable)
// interpolation is a single pass: a param value is never re-parsed as a
// placeholder, so user input like "{n}" in a first name is inert.
ctx.t("pair", { a: "{b}", b: "Z" }); // "{a} and {b}" → "{b} and Z"testing
with @yaebal/test, actors carry a languageCode, so detection and persistence are testable without any network.
import { createTestEnv } from "@yaebal/test";
const env = createTestEnv(bot);
// language_code drives first-contact detection
const user = env.createUser({ languageCode: "ru" });
await user.sendCommand("start");
// → the bot answered in russian on the very first update{placeholder} syntax, not template
literals. replacement is a single pass via String(value); unknown placeholders stay
verbatim and param values are never re-parsed. changeLanguage takes effect immediately within the current handler — calls to
ctx.t and reads of ctx.locale after await ctx.changeLanguage("ru") use the new locale. the next update loads the
persisted locale from storage. updates without a chat or user (where
getKey returns undefined) still detect via language_code when present, but never
persist. plural-lookalike namespaces: an object whose keys are all plural categories (with
other present, all strings) is a plural set; any other object
nests. { other: "…", first: "…" } therefore nests as x.other / x.first.see it in a bigger bot: examples/basic and examples/commerce-suite.