Common pitfalls
Whether you’re setting up your first project or preparing a production deploy, these are the things that trip people up most often. We’ve collected them here so you don’t have to discover them the hard way.
Missing decorator support in tsconfig
Section titled “Missing decorator support in tsconfig”Warden relies on TypeScript decorators for auto-discovery. If your tsconfig.json is missing the required compiler options, decorators will silently do nothing — your commands, events, and components simply won’t be found.
{ "compilerOptions": { "experimentalDecorators": true, "emitDecoratorMetadata": true }}Config files belong in .config(...), not .interactions(...)
Section titled “Config files belong in .config(...), not .interactions(...)”Each .discover(...) bucket is strict — .interactions(...) only accepts classes with an interaction decorator. Drop a Config.define(...) file under src/commands/ and discovery rejects it at boot with a DiscoveryKindError. Keep config files under src/config/ and discover them via .config('./src/config/**/*.ts') (which lives in bootstrap.ts so both app.ts and cli.ts see them). Project structure walks through the full layout.
Plugin registration order
Section titled “Plugin registration order”Plugins are initialized in the order you list them. Most of the time this doesn’t matter — but if a plugin’s boot() method depends on bindings from another plugin, the dependency must come first:
// ✓ Drizzle registers first, so HeartbeatServiceProvider can resolve DrizzleService in boot().plugins([DrizzleServiceProvider, CacheServiceProvider, HeartbeatServiceProvider])
// ✗ HeartbeatServiceProvider boots before Drizzle is available.plugins([HeartbeatServiceProvider, DrizzleServiceProvider, CacheServiceProvider])Commands & interactions
Section titled “Commands & interactions”Subcommands can’t use interaction.reply() directly
Section titled “Subcommands can’t use interaction.reply() directly”When you use the command group pattern for subcommands, the entrypoint automatically calls deferReply() before routing to your subcommand. This means the interaction is already acknowledged — calling interaction.reply() directly will throw.
// ✗ Throws: "Interaction has already been acknowledged"await interaction.reply('Done!');
// ✓ Use the reply() helper — it detects the state automaticallyawait reply(interaction, 'Done!');Missing autocomplete() method
Section titled “Missing autocomplete() method”If you mark an option with .autocomplete() but forget to add the autocomplete() method on the command class, the option shows up in Discord but silently returns no suggestions when users type. The framework logs a warning at boot, but it’s easy to miss in the console noise.
@command({name: 'search', description: 'Search cases'})export default class SearchCommand implements Command { public options = [ Options.string('query', 'Search').autocomplete(), // ← this needs a matching method ];
// ✗ Forgot the autocomplete() method — Discord shows nothing when typing
public async execute(interaction: CommandInteraction, params: Params): Promise<void> { // ... }}Autocomplete must be fast
Section titled “Autocomplete must be fast”Discord doesn’t show a loading state during autocomplete. If your handler takes too long (heavy database queries, external API calls), the user sees an empty dropdown with no indication that something is happening.
Collectors need .from() in moderation flows
Section titled “Collectors need .from() in moderation flows”When you use a Collector for confirmation dialogs (like “Are you sure you want to ban this user?”), always scope it to the user who initiated the action. Without .from(), anyone can click the button.
// ✗ Any user in the channel can click "Confirm Ban"const response = await new Collector(interaction) .buttons('confirm', 'cancel') .collect();
// ✓ Only the moderator who ran the command can confirmconst response = await new Collector(interaction) .buttons('confirm', 'cancel') .from(interaction.user) .collect();Add NotSelf middleware to context menus
Section titled “Add NotSelf middleware to context menus”Right-click context menus make it surprisingly easy to accidentally target yourself — especially in a busy member list. Warden ships NotSelf as built-in middleware for exactly this reason. Import it from @warden/core and add it to your context menu decorator:
import {context, NotSelf} from '@warden/core';
@context({name: 'Warn User', type: 'user', middleware: [NotSelf]})Command registration
Section titled “Command registration”Guild-to-global migration leaves duplicates
Section titled “Guild-to-global migration leaves duplicates”When you switch from guild-scoped registration (using DEV_GUILD_ID) to global registration, the old guild-scoped commands don’t disappear. Users see duplicate entries in the command picker — one from the guild registration and one from the global registration.
# Clean up stale guild registrationspnpm warden sync:cleanDeleted commands stick around in Discord
Section titled “Deleted commands stick around in Discord”When you delete a command class, the framework no longer knows it exists — so the normal diff process can’t remove it from Discord. The command stays visible to users but throws a routing error when used.
# Remove commands that no longer exist in your codebasepnpm warden sync:cleanGlobal commands take up to an hour to propagate
Section titled “Global commands take up to an hour to propagate”Discord caches global commands aggressively. When you register or update a command globally, it can take up to an hour before all users see the change. This is a Discord limitation, not a Warden one.
Production deployment
Section titled “Production deployment”Preserve directory structure in the build
Section titled “Preserve directory structure in the build”Warden’s .discover() relies on the file structure to find handlers. If your build tool bundles everything into a single file, the discovery globs pointed at ./dist/...js won’t match anything.
export default { entry: ['src/**/*.ts'], format: ['esm'], outDir: 'dist', bundle: false, // ← critical: compile, don't bundle clean: true,};Docker: use exec form for the entrypoint
Section titled “Docker: use exec form for the entrypoint”If you’re running in Docker, the shell form of CMD doesn’t forward signals to the Node.js process. This means SIGTERM from docker stop never reaches your bot, graceful shutdown never triggers, and Docker kills the process after the timeout.
# ✗ Shell form — signals don't reach Node.jsCMD node dist/app.js
# ✓ Exec form — signals forwarded correctlyCMD ["node", "dist/app.js"]Forgetting HEARTBEAT_URL in production
Section titled “Forgetting HEARTBEAT_URL in production”The heartbeat plugin is designed to silently do nothing if HEARTBEAT_URL isn’t set — this is intentional for local development. But it means you can deploy to production thinking you have monitoring when you don’t.
Scheduled job timezone
Section titled “Scheduled job timezone”All @job() schedules run in your server’s local timezone. Cloud containers often default to UTC, which may not match your expectations. A job scheduled for every.day.at('09:00') will run at 9 AM UTC, not 9 AM in your local timezone.
Cooldowns auto-upgrade to Redis
Section titled “Cooldowns auto-upgrade to Redis”When you install @warden/cache, the cooldown system automatically switches from in-memory storage to Redis — no code changes needed. This is usually what you want, but it can be surprising: cooldowns suddenly persist across bot restarts and are shared across instances.
If you want the cache plugin for other things (like CacheService.remember()) but don’t want cooldowns to persist, you can override the storage backend explicitly:
export default { cooldowns: { storage: 'memory', // always in-memory, even with @warden/cache installed },};The available values are 'auto' (default — use Redis if available, otherwise in-memory), 'memory' (always in-memory), and 'cache' (always use the cache plugin — throws at boot if it’s not installed).