Coming from discord.js
Introduction
Section titled “Introduction”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?
The problem with raw discord.js
Section titled “The problem with raw discord.js”You’ve probably seen (or written) code like this:
// The classic raw discord.js botconst 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 manuallyconst 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
interactionCreatehandler - 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
Commands
Section titled “Commands”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 manuallyconst 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)), ],});import {command, permissions, cooldown, Command, Options, CommandInteraction, Params, reply, Embed} from '@warden/core';
@command({name: 'warn', description: 'Warn a user'})@permissions({user: ['ModerateMembers']})@cooldown({duration: 5, scope: 'user'})export default class WarnCommand implements Command { public options = [ Options.user('user', 'User to warn').required(), Options.string('reason', 'Reason').required().maxLength(500), ];
public async execute(interaction: CommandInteraction, params: Params): Promise<void> { const target = interaction.options.getUser('user')!; const reason = interaction.options.getString('reason')!;
await reply(interaction, Embed.success(`Warned ${target.username}`) .addFields({name: 'Reason', value: reason}) .setTimestamp() ); }}What changed:
- The command is self-contained — its name, description, options, permissions, cooldown, and handler are all in one place
- No manual registration —
scan()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
Events
Section titled “Events”// Raw discord.jsclient.on('guildMemberAdd', async (member) => { const channel = member.guild.systemChannel; if (channel) await channel.send(`Welcome, ${member.user.username}!`);});@event({name: 'MemberJoinLog', event: 'guildMemberAdd'})export default class MemberJoinLogEvent implements Event<'guildMemberAdd'> { constructor(private config: Config) {}
public async execute(member: GuildMember): Promise<void> { const channelId = this.config.get('moderation.logChannel') as string; const channel = member.guild.channels.cache.get(channelId) as TextChannel; 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
guildMemberRoleAddinstead of diffingguildMemberUpdate
That last one is huge. In raw discord.js, logging role changes means:
// Raw discord.js — painfulclient.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...});@event({name: 'RoleAdded', event: 'guildMemberRoleAdd'})export default class RoleAddedEvent implements Event<'guildMemberRoleAdd'> { public async execute(member: GuildMember, role: Role): Promise<void> { // that's it — you get the member and the specific role }}Buttons, modals & select menus
Section titled “Buttons, modals & select menus”In raw discord.js, you handle all component interactions in the same interactionCreate listener:
// Raw discord.jsclient.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 } }});@button({customId: 'confirm-ban'})export default class ConfirmBanButton implements Button { public async execute(interaction: ButtonInteraction, params: Params): Promise<void> { await reply(interaction, 'Banned!'); }}
@button({customId: 'delete/:userId'})export default class DeleteButton implements Button { public async execute(interaction: ButtonInteraction, params: Params): Promise<void> { const userId = params.get('userId')!; // no manual parsing }}
@modal({customId: 'warn-reason/:targetId'})export default class WarnReasonModal implements Modal { public async execute(interaction: ModalSubmitInteraction, params: Params): Promise<void> { const targetId = params.get('targetId')!; const reason = interaction.fields.getTextInputValue('reason'); }}And you build components with shorthand builders instead of the verbose discord.js way:
// Raw discord.jsconst row = new ActionRowBuilder<ButtonBuilder>().addComponents( new ButtonBuilder().setCustomId('confirm-ban').setLabel('Confirm').setStyle(ButtonStyle.Danger), new ButtonBuilder().setCustomId('cancel').setLabel('Cancel').setStyle(ButtonStyle.Secondary),);// Wardenconst row = Row( Btn.danger('Confirm', 'confirm-ban'), Btn.secondary('Cancel', 'cancel'),);Error handling
Section titled “Error handling”// Raw discord.js — inconsistent at besttry { 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 }}@errorHandler()export default class AppErrorHandler extends ErrorHandler { constructor(private logger: Logger) {}
public async handle(error: Error, context: ErrorContext): Promise<void> { this.logger.error(`Error in ${context.type}: ${context.name}`, error);
if (context.interaction) { await reply(context.interaction, { content: 'Something went wrong.', ephemeral: true, }); } }}Every command, event, button, modal, and job is covered. No more try/catch in every handler.
Configuration
Section titled “Configuration”// Raw discord.jsconst 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 validationimport {env} from '@warden/core';
export default { logChannel: env('MOD_LOG_CHANNEL_ID'), maxWarnings: env.number('MAX_WARNINGS', 3), muteRole: env('MUTE_ROLE_ID'),};
// In any classconstructor(private config: Config) {}const max = this.config.get('moderation.maxWarnings');One file per feature, typed env reading, injectable anywhere.
What stays the same
Section titled “What stays the same”Warden doesn’t replace discord.js — it builds on it. Everything you know still works:
- Interaction methods —
interaction.reply(),interaction.followUp(),interaction.showModal()all work exactly the same - discord.js types —
GuildMember,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 standardEmbedBuilder - Components — Warden’s
Btn.danger()returns a standardButtonBuilder
You’re not learning a new Discord library. You’re adding structure to the one you already know.
What you gain
Section titled “What you gain”- 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 builders —
Row(Btn.danger(...))instead of verbose builders - Path-to-regexp —
ban/:userIdwith auto-encoding - Configuration —
env()helper, config files, injectable service - Plugin ecosystem — database, cache, jobs, permissions, audit, i18n
- CLI —
warden make:command,pnpm create @warden - Testing —
TestBot,FakeInteraction, Vitest - HMR — hot reload in development
- Command diffing — only syncs changes to Discord
- Utilities —
reply(),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.