Autocomplete
Introduction
Section titled “Introduction”When a moderator needs to look up a past case, search for a user by partial name, or pick from a list of configured filters, typing a full value from memory is painful. Autocomplete solves this by showing suggestions as the user types, turning a guessing game into a guided experience.
Discord supports autocomplete on string, integer, and number options. Warden makes it easy to wire up: mark the option with .autocomplete(), add an autocomplete() method to your command class, and the framework handles routing the interaction to the right place.
How it works
Section titled “How it works”The flow is straightforward:
- You define an option with
.autocomplete()in youroptionsarray - As the user types in that option field, Discord sends an autocomplete interaction to your bot
- Warden routes it to the
autocomplete()method on the same command class - Your method returns up to 25 suggestions via
interaction.respond() - The user picks one (or keeps typing)
The key thing to understand is that autocomplete fires on every keystroke. Your autocomplete() method should be fast — avoid heavy database queries without filtering, and keep your response under the 25-result limit that Discord enforces.
Enabling autocomplete on an option
Section titled “Enabling autocomplete on an option”To enable autocomplete, chain .autocomplete() on a string, integer, or number option:
public options = [ Options.string('query', 'Search moderation cases').autocomplete(), Options.string('reason', 'Reason for the action').required().maxLength(500),];Only the query option will trigger autocomplete interactions. The reason option is a normal text input — .autocomplete() and .choices() are mutually exclusive, since choices already provide a fixed list.
Writing the autocomplete method
Section titled “Writing the autocomplete method”Add a public autocomplete() method to the same command class. It receives the raw AutocompleteInteraction from discord.js:
import {command, Command, CommandInteraction, Params, Options} from '@warden/core';import {AutocompleteInteraction} from 'discord.js';
@command({parent: 'mod', name: 'case', description: 'Look up a moderation case'})export default class ModCaseCommand extends Subcommand { public options = [ Options.string('query', 'Case number, username, or reason').required().autocomplete(), ];
constructor(private caseService: CaseService) {}
public async autocomplete(interaction: AutocompleteInteraction): Promise<void> { const query = interaction.options.getFocused(); const cases = await this.caseService.search(query, {limit: 25});
await interaction.respond( cases.map(c => ({ name: `#${c.id} - ${c.type} - ${c.targetUsername} - ${c.reason.slice(0, 60)}`, value: String(c.id), })), ); }
public async execute(interaction: CommandInteraction, params: Params): Promise<void> { const caseId = interaction.options.getString('query')!; const modCase = await this.caseService.findOrFail(Number(caseId));
await interaction.followUp({embeds: [ Embed.info(`Case #${modCase.id}`) .addFields( {name: 'Type', value: modCase.type, inline: true}, {name: 'User', value: modCase.targetUsername, inline: true}, {name: 'Moderator', value: modCase.moderatorUsername, inline: true}, {name: 'Reason', value: modCase.reason}, ) .setTimestamp(modCase.createdAt) ]}); }}The name field is what the user sees in the suggestion dropdown (keep it under 100 characters). The value is what gets submitted when they pick it — typically an ID or key that your execute() method can use to fetch the full record.
Filtering and ranking suggestions
Section titled “Filtering and ranking suggestions”Since autocomplete fires on every keystroke, you’ll want to filter results efficiently. A few approaches:
The most efficient approach — let your database do the work:
public async autocomplete(interaction: AutocompleteInteraction): Promise<void> { const query = interaction.options.getFocused();
// search by case number, username, or reason text const cases = await this.caseService.search(query, { guildId: interaction.guildId!, limit: 25, });
await interaction.respond( cases.map(c => ({ name: `#${c.id} - ${c.type} - ${c.targetUsername}`, value: String(c.id), })), );}For smaller datasets like a fixed list of filter categories or rule names, filtering in memory is perfectly fine:
public async autocomplete(interaction: AutocompleteInteraction): Promise<void> { const query = interaction.options.getFocused().toLowerCase(); const ruleNames = this.config.get('moderation.rules') as string[];
const matches = ruleNames .filter(r => r.toLowerCase().includes(query)) .slice(0, 25) .map(r => ({name: r, value: r}));
await interaction.respond(matches);}When the user hasn’t typed anything yet, getFocused() returns an empty string. You may want to show popular or recent results as a starting point:
public async autocomplete(interaction: AutocompleteInteraction): Promise<void> { const query = interaction.options.getFocused();
const cases = query.length > 0 ? await this.caseService.search(query, {limit: 25}) : await this.caseService.getRecent(interaction.guildId!, 25);
await interaction.respond( cases.map(c => ({ name: `#${c.id} - ${c.type} - ${c.targetUsername}`, value: String(c.id), })), );}Multiple autocomplete options
Section titled “Multiple autocomplete options”A command may have more than one autocomplete-enabled option. Instead of branching on the focused option name inside a single autocomplete() method, you can define a named method per option:
@command({parent: 'mod', name: 'search', description: 'Search moderation history'})export default class ModSearchCommand extends Subcommand { public options = [ Options.string('user', 'Search by username').autocomplete(), Options.string('reason', 'Search by reason text').autocomplete(), ];
constructor(private caseService: CaseService) {}
public async autocompleteUser(value: string): Promise<AutocompleteChoice[]> { const users = await this.caseService.searchUsers(value, {limit: 25}); return users.map(u => ({name: u.username, value: u.id})); }
public async autocompleteReason(value: string): Promise<AutocompleteChoice[]> { const reasons = await this.caseService.searchReasons(value, {limit: 25}); return reasons.map(r => ({name: r.slice(0, 100), value: r.slice(0, 100)})); }
public async execute(interaction: CommandInteraction, params: Params): Promise<void> { // ... }}The framework converts the focused option name to a method name — user becomes autocompleteUser, search-query becomes autocompleteSearchQuery — and calls it with the focused value as the first argument. You return an array of choices and the framework calls interaction.respond() for you.
This is cleaner than the alternative of branching manually inside a single method, especially as the number of autocomplete options grows.
Fallback to generic autocomplete()
Section titled “Fallback to generic autocomplete()”If no named method is found for the focused option, the framework falls back to the generic autocomplete(interaction) method. This means existing code continues to work — you can adopt named methods incrementally, or use the generic method when a single handler makes more sense:
// Still works — handles all autocomplete options in one placepublic async autocomplete(interaction: AutocompleteInteraction): Promise<void> { const focused = interaction.options.getFocused(true);
if (focused.name === 'user') { // ... }
if (focused.name === 'reason') { // ... }}Boot-time validation
Section titled “Boot-time validation”Warden validates your autocomplete setup when the bot starts. If you mark an option with .autocomplete() but the framework can’t find a matching handler — neither a named method (autocompleteUser) nor a generic autocomplete() method — you’ll see a warning in your console:
[Warden] Warning: Command "mod search" has autocomplete option "user" but no autocompleteUser() or autocomplete() method.This isn’t a hard error — the bot still starts — but the autocomplete interaction will silently fail at runtime. The warning is there to save you from a confusing debugging session where the option shows up in Discord but never returns suggestions.
A complete example
Section titled “A complete example”Here’s a fully fleshed-out moderation case lookup that demonstrates everything working together — autocomplete, DI, subcommands, and permissions:
import {command, Options, Params, Embed} from '@warden/core';import {permissions} from '@warden/core';import {AutocompleteInteraction, CommandInteraction} from 'discord.js';
@command({parent: 'mod', name: 'case', description: 'Look up a moderation case'})@permissions({user: ['ModerateMembers']})export default class ModCaseCommand extends Subcommand { public options = [ Options.string('query', 'Case number, username, or reason').required().autocomplete(), ];
constructor(private caseService: CaseService, private logger: Logger) {}
public async autocomplete(interaction: AutocompleteInteraction): Promise<void> { const query = interaction.options.getFocused();
try { const cases = query.length > 0 ? await this.caseService.search(query, { guildId: interaction.guildId!, limit: 25, }) : await this.caseService.getRecent(interaction.guildId!, 10);
await interaction.respond( cases.map(c => ({ name: `#${c.id} - ${c.type} - ${c.targetUsername} - ${c.reason.slice(0, 50)}`, value: String(c.id), })), ); } catch (error) { this.logger.warn('Autocomplete failed for mod case', error); await interaction.respond([]); } }
public async execute(interaction: CommandInteraction, params: Params): Promise<void> { const caseId = Number(interaction.options.getString('query')!); const modCase = await this.caseService.findOrFail(caseId);
await interaction.followUp({embeds: [ Embed.info(`Case #${modCase.id}`) .addFields( {name: 'Type', value: modCase.type, inline: true}, {name: 'User', value: `<@${modCase.targetId}>`, inline: true}, {name: 'Moderator', value: `<@${modCase.moderatorId}>`, inline: true}, {name: 'Reason', value: modCase.reason}, {name: 'Date', value: modCase.createdAt.toISOString(), inline: true}, ) .setTimestamp(modCase.createdAt) ]}); }}A few things worth noting:
- The
autocomplete()method is wrapped in a try/catch. If the database is slow or unreachable, we return an empty array rather than letting the error propagate (Discord doesn’t show autocomplete errors to the user anyway) - The
namefield in suggestions is truncated to stay readable in the dropdown - When the user hasn’t typed anything, we show the 10 most recent cases as a starting point
- Full DI works in
autocomplete()just like it does inexecute()— same class, same constructor injections