Spraxium logoSpraxium

i18n Fundamentals

The i18n module turns a single-language bot into one that responds in the language each user speaks. Learn how to configure locale files, inject the translation service, and persist user preferences.

Why a structured i18n system matters

Most bots start with hardcoded English strings scattered across handler files, and that works fine for the first few hundred users. The problem surfaces when the community grows to include guilds in Brazil, Spain, Japan, or Korea: the only path to supporting those users is manually tracing every string through the codebase and wrapping each one in conditional logic. That approach does not scale, breaks across refactors, and mixes presentation concerns directly into command logic.

Spraxium's i18n module solves this with a centralized registry of translated strings loaded once at boot, a resolution pipeline that automatically picks the right locale for every interaction, and helpers that wire slash command localizations directly through the command decorator. Writing an internationalized bot looks almost identical to writing a monolingual one, because the complexity is absorbed by the framework rather than scattered across handler methods.

Localization is not just about text replacement. It is about respecting that users have a preferred language, that Discord itself has a locale system affecting how commands appear in the UI, and that pluralization rules differ significantly across languages. The module handles all three concerns in one cohesive system so you rarely need to think about the mechanics while writing handlers.

How locale resolution works

When a handler calls tUser, the service resolves the locale for that user through a three-tier priority chain: a saved user preference beats a saved guild preference, and a saved guild preference beats the configured default locale. This ordering is the correct model for Discord bots because it respects individual choices first, then community-level settings, and falls back to the bot-wide default only when no preference has been set at all.

User and guild preferences are stored in the active I18nStore backend and survive bot restarts as long as the store backend is persistent. The built-in MemoryStore works for local development and bots that do not need persistent preferences, while production bots should use a database-backed store such as those illustrated in the Store API page.

Slash command localizations follow a different path: when the bot registers commands with Discord's REST API, names and descriptions can have per-locale variants that Discord renders natively in the command picker. The i18n module wires those localizations automatically during the boot sequence, so declaring an i18n field on a slash decorator is all that is needed to produce a fully localized command entry in Discord's UI.

Configuring the module

config/i18n.config.ts
import { defineI18n } from '@spraxium/i18n';

export const i18nConfig = defineI18n({
  defaultLocale: 'en-US',
  references: [
    { name: 'commands', path: './locales/commands' },
    { name: 'messages', path: './locales/messages' },
    { name: 'errors', path: './locales/errors' },
  ],
});

Add the config object to the plugins array in your Spraxium config. The module is self-contained and registers its own I18nLifecycle, which runs at boot automatically before any slash commands are registered.

FieldTypePurpose
defaultLocalestring

Fallback locale used when no user or guild preference is set

referencesArrayNamed pointers to locale directories on disk
pluralRulesRecord

Optional custom plural functions per locale, overriding Intl.PluralRules

The I18nService API

Inject I18nService into any handler or provider. It exposes both synchronous and asynchronous translation methods to cover every usage pattern your bot needs.

import { Ctx, SlashCommandHandler } from '@spraxium/common';
import { I18nService } from '@spraxium/i18n';
import type { ChatInputCommandInteraction } from 'discord.js';
import { PingCommand } from '../commands/ping.command';

@SlashCommandHandler(PingCommand)
export class PingHandler {
  constructor(private readonly i18n: I18nService) {}

  async handle(@Ctx() interaction: ChatInputCommandInteraction): Promise<void> {
    const locale = interaction.locale;
    const message = this.i18n.t('commands.ping.response', locale);
    await interaction.reply(message);
  }
}
MethodAsyncWhat it does
t(key, locale, vars?)NoTranslate key for an explicit locale with optional variable substitution
tp(key, locale, count, vars?)NoTranslate with pluralization for an explicit locale
tUser(userId, key, vars?, guildId?)YesTranslate after resolving locale via user and guild preference chain
tpUser(userId, key, count, vars?, guildId?)YesPluralized translation after resolving locale via preference chain
setUserLocale(userId, locale)YesPersist a locale preference for a user
setGuildLocale(guildId, locale)YesPersist a locale preference for a guild
getUserLocale(userId)YesRetrieve the user's stored locale or the default
getGuildLocale(guildId)YesRetrieve the guild's stored locale or the default
resolveLocale(userId, guildId?)YesRun the full resolution chain and return the winning locale