Skip to content

Internationalization

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.

Terminal window
pnpm add @warden/i18n

Register 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();

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:

src/lang/en/commands.json
{
"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}"
}
}
src/lang/nl/commands.json
{
"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}"
}
}
src/lang/en/errors.json
{
"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."
}
src/lang/nl/errors.json
{
"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.

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.

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});

When you call this.i18n.for(interaction), the plugin determines the locale using a fallback chain:

  1. User preference — if the user has set a language via /language set (see below)
  2. Guild preference — if the server admin has set a default language for the server
  3. Discord locale — the locale from the user’s Discord client settings
  4. 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:

src/config/i18n.ts
export default {
defaultLocale: 'en',
commands: true, // enable built-in /language commands
};

Pluralization is handled automatically based on the locale. Use the ICU plural syntax in your translation strings:

src/lang/en/commands.json
{
"warn": {
"summary": "{count, plural, one {# warning} other {# warnings}} issued to {user}."
},
"cleanup": {
"result": "Removed {count, plural, one {# expired mute} other {# expired mutes}}."
}
}
src/lang/nl/commands.json
{
"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.

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

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 languages

These 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).

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() method
i18n.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.

@warden/i18n includes a few features that make working with translations during development much smoother.

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.

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.

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

This helps you catch forgotten translations before they reach production.

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()
]});
}
}

Use i18n in your error handler for locale-aware error messages:

src/lang/en/errors.json
{
"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."
}
src/lang/nl/errors.json
{
"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')));
}
}

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()
]});
}
}
}