Skip to content

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.

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.

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

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 automatically
await reply(interaction, 'Done!');

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> {
// ...
}
}

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 confirm
const response = await new Collector(interaction)
.buttons('confirm', 'cancel')
.from(interaction.user)
.collect();

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

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.

Terminal window
# Clean up stale guild registrations
pnpm warden sync:clean

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.

Terminal window
# Remove commands that no longer exist in your codebase
pnpm warden sync:clean

Global 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.

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.

tsup.config.ts
export default {
entry: ['src/**/*.ts'],
format: ['esm'],
outDir: 'dist',
bundle: false, // ← critical: compile, don't bundle
clean: true,
};

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.js
CMD node dist/app.js
# ✓ Exec form — signals forwarded correctly
CMD ["node", "dist/app.js"]

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.

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.

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:

config/warden.ts
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).