Skip to content

Coming from Sapphire

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.

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

No constructor. No registerApplicationCommands. No LoaderContext. The @command() decorator takes care of all of it.

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

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.

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 command
export class WarnCommand extends Command {
public constructor(context: Command.LoaderContext, options: Command.Options) {
super(context, { ...options, preconditions: ['ModeratorOnly'] });
}
}

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']})

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

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

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

Same result, less noise.

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

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)

In Sapphire, you access shared services through a global container. In Warden, you use constructor injection instead — services are injected automatically:

this.container.client
this.container.logger
this.container.stores.get('commands')

This is a genuine upgrade. Constructor injection makes dependencies explicit, testable, and type-safe. No more reaching into a global container.

  • Decomposed events — 40+ specific events instead of manually diffing guildMemberUpdate
  • Component buildersRow(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 automatically
  • confirm() one-liner for confirmation dialogs
  • Embed.success/error/info/warning() presets
  • every.day.at('09:00') cron helpers
  • dd() / 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 messageCreate events, but there’s no built-in argument parser.
  • Result / Option types — 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.