Skip to content

Autocomplete

Suggest option values while someone types, so they pick from your data rather than guessing. Covers one arm per field, serving several commands from one handler, the string value you always receive, and reading the options they already filled in.

addChoices covers a list you know when you deploy, up to Discord's limit of 25. A catalogue you query, or anything that changes while the bot runs, needs the suggestions built per keystroke.

An option offers those once you call setAutocomplete(true) on it.

src/commands/Search.ts
import {
    BuilderComponent,
    RegisterCommand
} from '@seedcord/gateway';

@RegisterCommand('global')
export class Search extends BuilderComponent<'command'> {
    constructor() {
        super('command');

        this.instance
            .setName('search')
            .setDescription('Search the catalogue')
            .addStringOption((option) =>
                option
                    .setName('query')
                    .setDescription('What to search')
                    .setRequired(true)
                    .setAutocomplete(true)
            )
            .addIntegerOption((option) =>
                option
                    .setName('limit')
                    .setDescription('How many results')
                    .setAutocomplete(true)
            )
            .addStringOption((option) =>
                option
                    .setName('category')
                    .setDescription('Scope the query')
                    .addChoices(
                        { name: 'Books', value: 'books' },
                        { name: 'Films', value: 'films' }
                    )
            );
    }
}

Suggestions arrive as their own interaction, so you write them their own class and decorator. You give it the command's own name, so a mismatch is a compile error.

src/handlers/SearchAutocomplete.tshover for typestap for types, arrow keys walk the tokens
@AutocompleteRoute<"search">(...routes: "search"[]): <TCtor>(constructor: AssertAutocompleteRoute<"search", TCtor>) => voidAutocompleteRoute('search')
export class class SearchAutocompleteSearchAutocomplete extends 
class AutocompleteHandler<
    Route extends keyof SlashRegistry,
    Cache extends CacheType = CacheFor<Route>
>
AutocompleteHandler
<'search'> {
public async SearchAutocomplete.execute(): Promise<void>execute(): interface Promise<T>Promise<void> { await this.AutocompleteHandler<"search", "cached">.match<void>(arms: FocusedArms<"search", void>): Promise<void>match({ query: (value: string, respond: (choices: readonly ApplicationCommandOptionChoiceData<string>[]) => Promise<void>) => Promisable<void>query: (value: stringvalue, respond: (choices: readonly ApplicationCommandOptionChoiceData<string>[]) => Promise<void>respond) => respond: (choices: readonly ApplicationCommandOptionChoiceData<string>[]) => Promise<void>respond(this.
SearchAutocomplete.titles(value: string): {
    name: string;
    value: string;
}[]
titles
(value: stringvalue)),
limit: (value: string, respond: (choices: readonly ApplicationCommandOptionChoiceData<number>[]) => Promise<void>) => Promisable<void>limit: (value: stringvalue, respond: (choices: readonly ApplicationCommandOptionChoiceData<number>[]) => Promise<void>respond) => respond: (choices: readonly ApplicationCommandOptionChoiceData<number>[]) => Promise<void>respond(this.
SearchAutocomplete.counts(value: string): {
    name: string;
    value: number;
}[]
counts
(value: stringvalue))
}); } private
SearchAutocomplete.titles(value: string): {
    name: string;
    value: string;
}[]
titles
(
value: stringvalue: string ): { name: stringname: string; value: stringvalue: string }[] { return const TITLES: string[]TITLES.Array<string>.filter(predicate: (value: string, index: number, array: string[]) => unknown, thisArg?: any): string[] (+1 overload)filter((title: stringtitle) => title: stringtitle.String.toLowerCase(): stringtoLowerCase().String.startsWith(searchString: string, position?: number): booleanstartsWith(value: stringvalue.String.toLowerCase(): stringtoLowerCase()) ).
Array<string>.map<{
    name: string;
    value: string;
}>(callbackfn: (value: string, index: number, array: string[]) => {
    name: string;
    value: string;
}, thisArg?: any): {
    name: string;
    value: string;
}[]
map
((title: stringtitle) => ({ name: stringname: title: stringtitle, value: stringvalue: title: stringtitle }));
} private
SearchAutocomplete.counts(value: string): {
    name: string;
    value: number;
}[]
counts
(
value: stringvalue: string ): { name: stringname: string; value: numbervalue: number }[] { const const parsed: numberparsed = var Number: NumberConstructorNumber.NumberConstructor.parseInt(string: string, radix?: number): numberparseInt(value: stringvalue, 10); return var Number: NumberConstructorNumber.NumberConstructor.isNaN(number: unknown): booleanisNaN(const parsed: numberparsed) ? [] : [{ name: stringname: `${const parsed: numberparsed}`, value: numbervalue: const parsed: numberparsed }]; } }

Discord sends one of these every time the person edits the field, so value is whatever they've typed so far.

One arm per field

An autocomplete interaction says which field is focused, so every handler branches on it. this.match takes one arm per autocompletable field, and an arm you leave out fails to compile.

Each arm receives the partial value and a respond typed to that field's choice values.

Typing the opening quote shows the fields that command declared.

await this.match({ '
  • limit
  • query

One handler for several commands

Name every command in the decorator, and repeat those names in the generic, so the arms reach every autocompletable field across all of them.

search completes query and limit. A second command, probe, completes query, count, and ratio. One handler for both needs four arms.

@AutocompleteRoute('search', 'probe')
export class Catalog extends AutocompleteHandler<
    'search' | 'probe'
> {
    public async execute(): Promise<void> {
        await this.match({
            query: (value, respond) => respond([]),
            limit: (value, respond) => respond([]),
            count: (value, respond) => respond([]),
            ratio: (value, respond) => respond([])
        });
    }
}

Both commands complete query, so its arm reads this.route to tell them apart.

const command = this.route;
const command: "search" | "probe"

The value is always a string

limit is an integer option. Its partial still arrives as a string.

await this.match({
    query: (value, respond) => respond([]),
    limit: (value, respond) => respond([])
value: string
});

Discord sends the raw text, so someone typing 120 sends '1', then '12', then '120'. Parse it yourself and guard the result, since an empty field sends an empty string.

What you send back carries the real type. limit takes { value: number }.

Reading the other options

this.options holds what the person has already filled in, which is how a suggestion narrows to their earlier answers.

const category = this.options.getString('category');
const category: "books" | "films" | null

category comes back nullable, along with every other read here, since the person might still be on an earlier option.

Discord resolves these option kinds mid-type, so getString, getInteger, getNumber, and getBoolean are the only getters you get here.