Skip to content

Autocomplete

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.

The flow is straightforward:

  1. You define an option with .autocomplete() in your options array
  2. As the user types in that option field, Discord sends an autocomplete interaction to your bot
  3. Warden routes it to the autocomplete() method on the same command class
  4. Your method returns up to 25 suggestions via interaction.respond()
  5. 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.

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.

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.

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

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.

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 place
public async autocomplete(interaction: AutocompleteInteraction): Promise<void> {
const focused = interaction.options.getFocused(true);
if (focused.name === 'user') {
// ...
}
if (focused.name === 'reason') {
// ...
}
}

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.

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 name field 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 in execute() — same class, same constructor injections