Coming from Sapphire
Introduction
Section titled “Introduction”If you’ve been building bots with Sapphire, welcome! You’ll find that many of the concepts you know translate directly to Warden — commands, events, preconditions, and plugins all have equivalents. The main difference is in how they’re expressed: where Sapphire uses class inheritance and file-system conventions, Warden uses decorators and auto-discovery.
This guide maps the concepts you already know to their Warden equivalents, so you can hit the ground running.
Commands
Section titled “Commands”In Sapphire, a command extends Command and lives in the commands/ directory. You implement chatInputRun() for slash commands. In Warden, the same command is significantly shorter. The decorator handles registration, and the Options builder replaces the verbose registerApplicationCommands boilerplate:
import { Command } from '@sapphire/framework';
export class WarnCommand extends Command { public constructor(context: Command.LoaderContext, options: Command.Options) { super(context, { ...options, name: 'warn', description: 'Warn a user', }); }
public override registerApplicationCommands(registry: Command.Registry) { registry.registerChatInputCommand((builder) => builder .setName('warn') .setDescription('Warn a user') .addUserOption((option) => option.setName('user').setDescription('User to warn').setRequired(true)) .addStringOption((option) => option.setName('reason').setDescription('Reason').setRequired(true)) ); }
public override async chatInputRun(interaction: Command.ChatInputCommandInteraction) { const target = interaction.options.getUser('user', true); const reason = interaction.options.getString('reason', true); await interaction.reply(`Warned ${target.username}: ${reason}`); }}import {command, Command, Options, CommandInteraction, Params, reply, Embed} from '@warden/core';
@command({name: 'warn', description: 'Warn a user'})export default class WarnCommand implements Command { public options = [ Options.user('user', 'User to warn').required(), Options.string('reason', 'Reason').required(), ];
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}: ${reason}`)); }}No constructor. No registerApplicationCommands. No LoaderContext. The @command() decorator takes care of all of it.
Events (listeners)
Section titled “Events (listeners)”Sapphire calls them “Listeners” — classes that extend Listener and specify which event to listen to. In Warden, the decorator replaces the constructor entirely:
import { Listener } from '@sapphire/framework';import type { GuildMember } from 'discord.js';
export class MemberJoinListener extends Listener { public constructor(context: Listener.LoaderContext, options: Listener.Options) { super(context, { ...options, event: 'guildMemberAdd', }); }
public override async run(member: GuildMember) { const channel = member.guild.systemChannel; if (channel) await channel.send(`Welcome, ${member.user.username}!`); }}import {event, Event} from '@warden/core';import {GuildMember} from 'discord.js';
@event({name: 'MemberJoinLog', event: 'guildMemberAdd'})export default class MemberJoinLogEvent implements Event<'guildMemberAdd'> { public async execute(member: GuildMember): Promise<void> { const channel = member.guild.systemChannel; if (channel) await channel.send(`Welcome, ${member.user.username}!`); }}One thing Warden adds that Sapphire doesn’t have: decomposed events. Instead of listening to guildMemberUpdate and manually diffing old and new state, you can listen to guildMemberRoleAdd, guildMemberTimeout, guildMemberBoost, and 40+ other specific events. See the Events guide for the full list.
Preconditions vs middleware
Section titled “Preconditions vs middleware”Sapphire’s preconditions check conditions before a command runs. In Warden, the equivalent is middleware — but with a few superpowers. You get typed middleware (per handler type), global middleware (runs on everything), and AND/OR composition:
import { Precondition } from '@sapphire/framework';
export class ModeratorOnlyPrecondition extends Precondition { public override async chatInputRun(interaction: Command.ChatInputCommandInteraction) { return interaction.memberPermissions?.has('ModerateMembers') ? this.ok() : this.error({ message: 'You need moderator permissions.' }); }}
// Attached to a commandexport class WarnCommand extends Command { public constructor(context: Command.LoaderContext, options: Command.Options) { super(context, { ...options, preconditions: ['ModeratorOnly'] }); }}import {Middleware, NextFn, CommandInteraction} from '@warden/core';
export default class IsModerator extends Middleware<CommandInteraction> { public async handle(ctx: CommandInteraction, next: NextFn<CommandInteraction>): Promise<void> { if (!ctx.memberPermissions?.has('ModerateMembers')) { throw new AuthorizationError(); } await next(ctx); }}
// Attached to a command@command({name: 'warn', description: 'Warn a user', middleware: [IsModerator]})Sapphire’s precondition composition (['GuildOnly', ['AdminOnly', 'ModOnly']]) maps directly to Warden’s AND/OR middleware composition — the syntax is identical.
For simple permission checks, Warden also offers the @permissions() decorator, which is even more concise:
@command({name: 'warn', description: 'Warn a user'})@permissions({user: ['ModerateMembers']})Interaction handlers
Section titled “Interaction handlers”In Sapphire, buttons, modals, and select menus use InteractionHandler with a parse() + run() two-phase pattern. In Warden, the custom ID is right in the decorator. No parse method, no this.some() / this.none():
import { InteractionHandler, InteractionHandlerTypes } from '@sapphire/framework';
export class ConfirmBanHandler extends InteractionHandler { public constructor(context: InteractionHandler.LoaderContext, options: InteractionHandler.Options) { super(context, { ...options, interactionHandlerType: InteractionHandlerTypes.Button }); }
public override parse(interaction: ButtonInteraction) { if (interaction.customId !== 'confirm-ban') return this.none(); return this.some(); }
public override async run(interaction: ButtonInteraction) { await interaction.reply('Banned!'); }}import {button, Button, Params, reply} from '@warden/core';import {ButtonInteraction} from 'discord.js';
@button({customId: 'confirm-ban'})export default class ConfirmBanButton implements Button { public async execute(interaction: ButtonInteraction, params: Params): Promise<void> { await reply(interaction, 'Banned!'); }}And Warden adds something Sapphire doesn’t have: path-to-regexp for dynamic custom IDs. Instead of manually parsing customId.split(':'), you declare a pattern:
@button({customId: 'ban/:userId'})export default class BanButton implements Button { public async execute(interaction: ButtonInteraction, params: Params): Promise<void> { const userId = params.get('userId')!; // extracted automatically }}Arguments vs options
Section titled “Arguments vs options”Sapphire has an extensive argument resolver system for message commands (prefix-based !warn @user reason). If you rely heavily on message commands, this is something Warden doesn’t have out of the box — Warden is slash-command-first.
For slash commands, Sapphire uses discord.js’s SlashCommandBuilder directly. Warden replaces this with the Options fluent builder:
registry.registerChatInputCommand((builder) => builder.addUserOption((option) => option.setName('user').setDescription('User to warn').setRequired(true) ));public options = [ Options.user('user', 'User to warn').required(), Options.string('reason', 'Reason').required().maxLength(500), Options.integer('duration', 'Minutes').min(1).max(1440),];Same result, less noise.
Plugins
Section titled “Plugins”Sapphire plugins hook into lifecycle phases (PreInitialization, PostInitialization, PreLogin, PostLogin). Warden plugins follow the same concept but with a cleaner lifecycle — register(), boot(), and shutdown():
SapphireClient.use(MyPlugin);new Bot() .plugins([DrizzleServiceProvider, CacheServiceProvider, HeartbeatServiceProvider]) .start();If you’ve written a Sapphire plugin, writing a Warden plugin will feel familiar. The main difference is that Warden plugins can use the .using() pattern for swappable backends:
CacheServiceProvider.using(MemoryAdapter)JobsServiceProvider.using(SQSAdapter)The container
Section titled “The container”In Sapphire, you access shared services through a global container. In Warden, you use constructor injection instead — services are injected automatically:
this.container.clientthis.container.loggerthis.container.stores.get('commands')@command({name: 'stats', description: 'Stats'})export default class StatsCommand implements Command { constructor( private logger: Logger, private config: Config, private caseService: CaseService, ) {}}This is a genuine upgrade. Constructor injection makes dependencies explicit, testable, and type-safe. No more reaching into a global container.
Things you’ll gain
Section titled “Things you’ll gain”- Decomposed events — 40+ specific events instead of manually diffing
guildMemberUpdate - Component builders —
Row(Btn.danger('Ban', 'ban/123'))instead of ActionRowBuilder boilerplate - Path-to-regexp for component custom IDs with auto-encoding
@cooldown()on any handler type, not just commands@permissions()as a simple decorator- Advanced permissions — custom roles, scopes, boundaries via
@warden/permissions - Audit logging —
@audit()decorator reply()helper that handles reply/followUp/editReply automaticallyconfirm()one-liner for confirmation dialogsEmbed.success/error/info/warning()presetsevery.day.at('09:00')cron helpersdd()/dump()for debugging- Proper DI instead of a service locator
- Typed configuration with
env()+ config files
Things you’ll miss (and what to do instead)
Section titled “Things you’ll miss (and what to do instead)”- Message commands — Warden is slash-command-first. A community plugin could add prefix command support using
messageCreateevents, but there’s no built-in argument parser. Result/Optiontypes — Sapphire’s Rust-inspired monadic types. In Warden, use standard try/catch with the error handler system.- Editable commands plugin — edit your message to re-run a command. Not applicable since Warden focuses on slash commands.
- API plugin — REST endpoints alongside the bot. Planned for Warden v1.1 as
@warden/api.