Internationalization
Introduction
Section titled “Introduction”If your bot serves communities that speak different languages, hardcoded English strings won’t cut it. A Dutch server deserves warning messages in Dutch, and a Spanish community shouldn’t have to guess what “You have been muted for violating rule 3” means.
@warden/i18n gives you a structured, file-based localization system with automatic locale detection, pluralization, and ICU message format support. It integrates deeply with the framework — every interaction carries locale context, and the translation API is available everywhere through dependency injection.
Installation
Section titled “Installation”pnpm add @warden/i18nRegister the plugin in your bootstrap file:
import {Bot} from '@warden/core';import {I18nServiceProvider} from '@warden/i18n';
new Bot() .discover(d => d .interactions('./src/{commands,buttons,modals,menus}/**/*.ts') .events('./src/events/**/*.ts')) .plugins([I18nServiceProvider]) .intents(i => i.defaults()) .partials(p => p.defaults()) .start();Translation files
Section titled “Translation files”Translations live in a nested directory structure under src/lang/. Each locale gets its own folder, and within that folder you can organize translations into as many JSON files as you like:
Directorysrc/
Directorylang/
Directoryen/
- commands.json
- errors.json
- automod.json
Directorynl/
- commands.json
- errors.json
- automod.json
The file name becomes the translation namespace. Here’s what these might look like:
{ "warn": { "success": "{user} has been warned.", "reason": "Reason: {reason}", "count": "This is warning #{count} for this user.", "threshold": "This user has reached {count} warnings and has been auto-muted." }, "mute": { "success": "{user} has been muted for {duration} minutes.", "expired": "{user}'s mute has expired." }, "ban": { "success": "{user} has been banned.", "reason": "Reason: {reason}" }}{ "warn": { "success": "{user} heeft een waarschuwing ontvangen.", "reason": "Reden: {reason}", "count": "Dit is waarschuwing #{count} voor deze gebruiker.", "threshold": "Deze gebruiker heeft {count} waarschuwingen bereikt en is automatisch gedempt." }, "mute": { "success": "{user} is gedempt voor {duration} minuten.", "expired": "De demp van {user} is verlopen." }, "ban": { "success": "{user} is verbannen.", "reason": "Reden: {reason}" }}{ "permission_denied": "You don't have permission to do that.", "target_not_found": "Could not find the specified user.", "self_moderation": "You can't moderate yourself.", "bot_moderation": "You can't moderate a bot.", "higher_role": "You can't moderate someone with a higher role than yours."}{ "permission_denied": "Je hebt geen toestemming om dit te doen.", "target_not_found": "De opgegeven gebruiker kon niet worden gevonden.", "self_moderation": "Je kunt jezelf niet modereren.", "bot_moderation": "Je kunt geen bot modereren.", "higher_role": "Je kunt niemand modereren met een hogere rol dan de jouwe."}Translations are loaded at boot and keyed by namespace.path — so commands.warn.success resolves to the nested value in commands.json.
The I18n injectable
Section titled “The I18n injectable”To use translations in your handlers, inject the I18n service and call .for(interaction) to get a translator bound to the correct locale:
import {I18n} from '@warden/i18n';
@command({parent: 'mod', name: 'warn', description: 'Warn a user'})@permissions({user: ['ModerateMembers']})export default class ModWarnCommand extends Subcommand { constructor(private i18n: I18n, private caseService: CaseService) {}
public async execute(interaction: CommandInteraction, params: Params): Promise<void> { const t = this.i18n.for(interaction); const target = interaction.options.getUser('user')!; const reason = interaction.options.getString('reason')!;
const caseNumber = await this.caseService.createWarning( interaction.guildId!, target.id, reason, interaction.user.id, );
const warningCount = await this.caseService.getWarningCount(target.id, interaction.guildId!);
await interaction.followUp({embeds: [ Embed.success(t('commands.warn.success', {user: target.username})) .addFields( {name: t('commands.warn.reason', {reason}), value: '\u200b'}, {name: t('commands.warn.count', {count: warningCount}), value: '\u200b'}, ) .setTimestamp() ]}); }}The t() function returned by .for(interaction) handles variable interpolation, pluralization, and locale resolution — all in one call.
Direct locale access
Section titled “Direct locale access”If you’re not in an interaction context — say, in a scheduled job or event handler — you can specify the locale directly:
const t = this.i18n.locale('nl');const message = t('commands.mute.expired', {user: username});Locale resolution
Section titled “Locale resolution”When you call this.i18n.for(interaction), the plugin determines the locale using a fallback chain:
- User preference — if the user has set a language via
/language set(see below) - Guild preference — if the server admin has set a default language for the server
- Discord locale — the locale from the user’s Discord client settings
- Default locale — the fallback specified in your i18n config (defaults to
'en')
This means a Dutch user in an English server sees Dutch messages, while an English user in the same server sees English — without anyone having to configure anything.
You can configure the default locale in your i18n config:
export default { defaultLocale: 'en', commands: true, // enable built-in /language commands};Pluralization
Section titled “Pluralization”Pluralization is handled automatically based on the locale. Use the ICU plural syntax in your translation strings:
{ "warn": { "summary": "{count, plural, one {# warning} other {# warnings}} issued to {user}." }, "cleanup": { "result": "Removed {count, plural, one {# expired mute} other {# expired mutes}}." }}{ "warn": { "summary": "{count, plural, one {# waarschuwing} other {# waarschuwingen}} gegeven aan {user}." }, "cleanup": { "result": "{count, plural, one {# verlopen demp} other {# verlopen demps}} verwijderd." }}t('commands.warn.summary', {count: 1, user: 'Alex'})// English: "1 warning issued to Alex."// Dutch: "1 waarschuwing gegeven aan Alex."
t('commands.warn.summary', {count: 5, user: 'Alex'})// English: "5 warnings issued to Alex."// Dutch: "5 waarschuwingen gegeven aan Alex."The pluralization rules are locale-aware — languages with complex plural forms (like Polish or Arabic) are handled correctly.
ICU message format
Section titled “ICU message format”Under the hood, @warden/i18n uses the ICU message format, which gives you more than just pluralization. You can use select expressions for gender, ordinals, and custom categories:
{ "action": { "completed": "{action, select, warn {warned} mute {muted} ban {banned} kick {kicked} other {moderated}} {user}." }}t('commands.action.completed', {action: 'ban', user: 'Alex'})// "banned Alex."
t('commands.action.completed', {action: 'mute', user: 'Alex'})// "muted Alex."Built-in language commands
Section titled “Built-in language commands”When you set commands: true in your i18n config, the plugin registers /language commands that let users and admins control their locale preferences:
/language set <locale> — set your personal language preference/language server <locale> — set the server's default language (admin only)/language reset — clear your personal preference/language list — show available languagesThese commands are entirely opt-in. If you’d rather handle locale preferences yourself — maybe through a settings panel or a different UX — just leave commands set to false (the default).
Plugin-aware translations
Section titled “Plugin-aware translations”Other Warden plugins can register their own translation namespaces. This means @warden/permissions, @warden/audit, or any third-party plugin can ship with translatable strings that integrate seamlessly.
For example, the permissions plugin might register its own error messages:
// Inside the permissions plugin's register() methodi18n.registerNamespace('permissions', { en: { denied: 'You don\'t have permission to perform this action.', role_assigned: '{role} has been assigned to {user}.', }, nl: { denied: 'Je hebt geen toestemming om deze actie uit te voeren.', role_assigned: '{role} is toegewezen aan {user}.', },});These are then accessible via t('permissions.denied') just like your own translations.
If you’re building a plugin and want to provide translatable strings, see Building Your Own Plugin.
Development experience
Section titled “Development experience”@warden/i18n includes a few features that make working with translations during development much smoother.
Missing translation warnings
Section titled “Missing translation warnings”If you reference a translation key that doesn’t exist, the plugin logs a warning at runtime instead of silently returning an empty string:
[i18n] Missing translation: "commands.kick.success" for locale "en"The missing key is returned as-is (e.g. commands.kick.success) so it’s immediately obvious in Discord that something is untranslated.
Hot module replacement
Section titled “Hot module replacement”In development mode (pnpm dev), translation files are watched for changes. Edit a JSON file and the translations are reloaded instantly — no bot restart needed. This makes iterating on copy fast and painless.
Validation at boot
Section titled “Validation at boot”At startup, the plugin checks that all locales have the same keys. If en/commands.json has a key that nl/commands.json is missing, you’ll see a warning:
[i18n] Missing keys in "nl/commands.json": warn.thresholdThis helps you catch forgotten translations before they reach production.
Practical examples
Section titled “Practical examples”Translating mod messages
Section titled “Translating mod messages”A complete warning command with full i18n support:
@command({parent: 'mod', name: 'warn', description: 'Warn a user'})@permissions({user: ['ModerateMembers']})@audit({action: 'moderation.warn', trackTarget: 'user'})export default class ModWarnCommand extends Subcommand { constructor( private i18n: I18n, private caseService: CaseService, private config: Config, ) {}
public options = [ Options.user('user', 'User to warn').required(), Options.string('reason', 'Reason for the warning').required().maxLength(500), ];
public async execute(interaction: CommandInteraction, params: Params): Promise<void> { const t = this.i18n.for(interaction); const target = interaction.options.getUser('user')!; const reason = interaction.options.getString('reason')!;
const warningCount = await this.caseService.createWarning( interaction.guildId!, target.id, reason, interaction.user.id, );
const maxWarnings = this.config.get('moderation.maxWarnings') as number;
if (warningCount >= maxWarnings) { await interaction.followUp({embeds: [ Embed.error(t('commands.warn.threshold', {count: warningCount})) ]}); return; }
await interaction.followUp({embeds: [ Embed.success(t('commands.warn.success', {user: target.username})) .addFields({name: t('commands.warn.reason', {reason}), value: '\u200b'}) .setFooter({text: t('commands.warn.count', {count: warningCount})}) .setTimestamp() ]}); }}Translated error messages
Section titled “Translated error messages”Use i18n in your error handler for locale-aware error messages:
{ "permission_denied": "You don't have permission to do that.", "cooldown": "Please wait {remaining} seconds before trying again.", "unknown": "Something went wrong. Please try again later."}{ "permission_denied": "Je hebt geen toestemming om dit te doen.", "cooldown": "Wacht {remaining} seconden voordat je het opnieuw probeert.", "unknown": "Er is iets misgegaan. Probeer het later opnieuw."}import {errorHandler, ErrorHandler} from '@warden/core';import {I18n} from '@warden/i18n';
@errorHandler()export default class ModBotErrorHandler implements ErrorHandler { constructor(private i18n: I18n) {}
public async handle(error: Error, interaction: CommandInteraction): Promise<void> { const t = this.i18n.for(interaction);
if (error instanceof AuthorizationError) { await reply(interaction, Embed.error(t('errors.permission_denied'))); return; }
await reply(interaction, Embed.error(t('errors.unknown'))); }}Locale-aware scheduled messages
Section titled “Locale-aware scheduled messages”Even scheduled jobs and events can use i18n. For guild-specific messages, use the guild’s preferred locale:
@scheduled({name: 'daily-mod-summary', schedule: '0 9 * * *'})export default class DailyModSummaryWorker implements Worker { constructor( private i18n: I18n, private caseService: CaseService, private guildService: GuildService, private bot: BotClient, ) {}
public async process(): Promise<void> { const guilds = await this.guildService.getAllActive();
for (const guild of guilds) { const t = this.i18n.locale(guild.preferredLocale ?? 'en'); const cases = await this.caseService.getYesterdaysCases(guild.id);
const channel = await this.bot.channels.fetch(guild.modLogChannel); await channel.send({embeds: [ Embed.info(t('reports.daily_summary', {count: cases.length})) .setTimestamp() ]}); } }}