Skip to content

Coming from discord.js

If you’ve been writing Discord bots with discord.js directly — no framework, just a client and some event listeners — Warden is going to feel like a breath of fresh air. Everything you know about discord.js still applies, because Warden is built on top of it. The difference is that Warden handles all the scaffolding, routing, and boilerplate that you’ve been writing by hand.

Think of it this way: discord.js is to Warden what Node’s http module is to Express. You can build everything from scratch, but why would you?

You’ve probably seen (or written) code like this:

// The classic raw discord.js bot
const client = new Client({intents: [...]});
client.on('interactionCreate', async (interaction) => {
if (!interaction.isChatInputCommand()) return;
if (interaction.commandName === 'warn') {
// check permissions manually
if (!interaction.memberPermissions?.has('ModerateMembers')) {
await interaction.reply({content: 'No permission.', ephemeral: true});
return;
}
// get options manually
const user = interaction.options.getUser('user', true);
const reason = interaction.options.getString('reason', true);
// do the thing
try {
await interaction.reply(`Warned ${user.username}: ${reason}`);
} catch (error) {
console.error(error);
await interaction.reply('Something went wrong.').catch(() => {});
}
}
if (interaction.commandName === 'ban') {
// another wall of code...
}
if (interaction.commandName === 'mute') {
// and another...
}
});
// Register commands manually
const rest = new REST().setToken(process.env.DISCORD_TOKEN!);
await rest.put(Routes.applicationCommands(clientId), {
body: [
new SlashCommandBuilder().setName('warn').setDescription('Warn a user')
.addUserOption(o => o.setName('user').setDescription('User').setRequired(true))
.addStringOption(o => o.setName('reason').setDescription('Reason').setRequired(true)),
// more commands...
],
});
client.login(process.env.DISCORD_TOKEN);

This works, but it has real problems:

  • Everything is in one giant interactionCreate handler
  • Permission checks are duplicated across commands
  • Error handling is inconsistent
  • Command registration is manual and separate from the handlers
  • Adding a new command means touching multiple places
  • There’s no structure — it’s all procedural

Here’s what that warn command looks like in Warden:

const client = new Client({intents: [...]});
client.on('interactionCreate', async (interaction) => {
if (!interaction.isChatInputCommand()) return;
if (interaction.commandName === 'warn') {
if (!interaction.memberPermissions?.has('ModerateMembers')) {
await interaction.reply({content: 'No permission.', ephemeral: true});
return;
}
const user = interaction.options.getUser('user', true);
const reason = interaction.options.getString('reason', true);
try {
await interaction.reply(`Warned ${user.username}: ${reason}`);
} catch (error) {
console.error(error);
await interaction.reply('Something went wrong.').catch(() => {});
}
}
});
// Register commands manually
const rest = new REST().setToken(process.env.DISCORD_TOKEN!);
await rest.put(Routes.applicationCommands(clientId), {
body: [
new SlashCommandBuilder().setName('warn').setDescription('Warn a user')
.addUserOption(o => o.setName('user').setDescription('User').setRequired(true))
.addStringOption(o => o.setName('reason').setDescription('Reason').setRequired(true)),
],
});

What changed:

  • The command is self-contained — its name, description, options, permissions, cooldown, and handler are all in one place
  • No manual registrationscan() discovers it, the framework registers it with Discord
  • No permission checking code@permissions() handles it declaratively
  • No error handling boilerplate — the framework catches errors and routes them to error handlers
  • No if (commandName === 'warn') — the framework routes interactions to the right class
// Raw discord.js
client.on('guildMemberAdd', async (member) => {
const channel = member.guild.systemChannel;
if (channel) await channel.send(`Welcome, ${member.user.username}!`);
});

The raw discord.js version is actually shorter here. But the Warden version gives you:

  • Dependency injection — inject services, config, logger, anything
  • Middleware — filter events before they reach your handler
  • Error handling — errors are caught and routed to error handlers, not swallowed
  • Decomposed events — listen to guildMemberRoleAdd instead of diffing guildMemberUpdate

That last one is huge. In raw discord.js, logging role changes means:

// Raw discord.js — painful
client.on('guildMemberUpdate', (oldMember, newMember) => {
const addedRoles = newMember.roles.cache.filter(r => !oldMember.roles.cache.has(r.id));
const removedRoles = oldMember.roles.cache.filter(r => !newMember.roles.cache.has(r.id));
// now figure out what changed and log it...
});

In raw discord.js, you handle all component interactions in the same interactionCreate listener:

// Raw discord.js
client.on('interactionCreate', async (interaction) => {
if (interaction.isButton()) {
if (interaction.customId === 'confirm-ban') {
// handle ban confirmation
}
if (interaction.customId.startsWith('delete:')) {
const userId = interaction.customId.split(':')[1];
// manually parse the custom ID
}
}
if (interaction.isModalSubmit()) {
if (interaction.customId === 'warn-reason') {
// handle modal
}
}
});

And you build components with shorthand builders instead of the verbose discord.js way:

// Raw discord.js
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().setCustomId('confirm-ban').setLabel('Confirm').setStyle(ButtonStyle.Danger),
new ButtonBuilder().setCustomId('cancel').setLabel('Cancel').setStyle(ButtonStyle.Secondary),
);
// Raw discord.js — inconsistent at best
try {
await interaction.reply('Done!');
} catch (error) {
console.error(error);
try {
await interaction.reply({content: 'Error', ephemeral: true});
} catch {
// interaction already replied? already deferred? who knows
}
}

Every command, event, button, modal, and job is covered. No more try/catch in every handler.

// Raw discord.js
const token = process.env.DISCORD_TOKEN;
const modLogChannel = process.env.MOD_LOG_CHANNEL_ID;
const maxWarnings = parseInt(process.env.MAX_WARNINGS ?? '3');
// scattered across files, no defaults, no validation

One file per feature, typed env reading, injectable anywhere.

Warden doesn’t replace discord.js — it builds on it. Everything you know still works:

  • Interaction methodsinteraction.reply(), interaction.followUp(), interaction.showModal() all work exactly the same
  • discord.js typesGuildMember, Message, ButtonInteraction, EmbedBuilder — all the same
  • The Client — it’s still a discord.js Client under the hood, accessible if you need it
  • Embeds — Warden’s Embed.success() returns a standard EmbedBuilder
  • Components — Warden’s Btn.danger() returns a standard ButtonBuilder

You’re not learning a new Discord library. You’re adding structure to the one you already know.

  • Project structure — commands, events, and components in organized, discoverable files
  • Auto-discovery — create a file, add a decorator, it works
  • Dependency injection — inject services, config, logger via constructor
  • Middleware — reusable pre-execution logic with AND/OR composition
  • Error handling — centralized, typed, never forgotten
  • Permissions & cooldowns — declarative, one line each
  • Decomposed events — 40+ specific events instead of manual diffing
  • Component buildersRow(Btn.danger(...)) instead of verbose builders
  • Path-to-regexpban/:userId with auto-encoding
  • Configurationenv() helper, config files, injectable service
  • Plugin ecosystem — database, cache, jobs, permissions, audit, i18n
  • CLIwarden make:command, pnpm create @warden
  • TestingTestBot, FakeInteraction, Vitest
  • HMR — hot reload in development
  • Command diffing — only syncs changes to Discord
  • Utilitiesreply(), confirm(), Embed.*, collect(), dd(), every.*

The bottom line: everything you’ve been building by hand, Warden does for you. You just write the parts that are unique to your bot.