@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

terminal
pnpm add @yaebal/i18n

usage

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.

bot.ts
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();
language-switch.ts
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.

typed.ts
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 locale

locale 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.

detection.ts
// 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

exportsignaturedescription
i18n(options) | (instance, options?) => Plugin<Context, I18nControls>the plugin — build from options, or wire a shared createI18n instance
createI18n(options: CreateI18nOptions) => I18nstandalone translator: t(locale, key, params?), locales, has, resolveLocale — for code outside middleware
I18nControlsinterfacewhat the plugin adds to the context (t, locale, locales, defaultLocale, changeLanguage)
Dictinterfacea locale's translation table: templates, plural sets, functions, nested tables
DictValuestring | PluralDict | DictFnone 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>typestructural check for non-default locales: same shape, every key optional
TFn<D> / DictKeys<D>typesthe typed translate function and its key union — exported for wrappers

options

fieldtyperequireddescription
defaultLocalekeyof localesyesthe fallback locale; its dict defines the typed key set
localesRecord<string, Dict>yesall translation dictionaries keyed by locale code
storageStorageAdapter<string>nowhere to persist each chat's locale. defaults to MemoryStorage
getKey(ctx) => string | undefinednostorage key for the update. default: chat id, falling back to user id
detectLocale(ctx) => string | undefinednofirst-contact detection, used only when nothing is stored. default: ctx.from?.language_code
onMissingKey(key, locale) => string | undefinednohook for keys missing in every locale; return a replacement or fall back to the key

I18nControls (added to ctx)

propertytypedescription
tTFn<D>translate a key of the default locale's dict; params and return type are inferred
localekeyof localesthe active locale for this update (reflects changeLanguage immediately)
localesreadonly (keyof locales)[]every configured locale — handy for building a /lang menu
defaultLocalekeyof localesthe 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.

redis-locale.ts
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.

per-user.ts
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.

plurals.ts
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.

formatted.ts
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.

standalone.ts
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

fallback.ts
// "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.

i18n.test.ts
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
interpolation uses {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.