Skip to content

One handler, several commands

One handler class can answer several slash commands and branch on the route that ran. Covers subcommands, the options every route shares, keeping both lists on the same routes, and the arm every route needs.

Two commands that share most of their work become two handler classes, plus a helper you import into both. Instead, just handle both in a single handler class. The decorator and the generic both take the list.

src/handlers/Moderation.ts
import { SlashHandler, SlashRoute } from '@seedcord/gateway';

@SlashRoute('ban', 'kick')
export class Moderation extends SlashHandler<'ban' | 'kick'> {
    public async execute(): Promise<void> {
        await this.match({
            ban: async (options) => {
                const reason = options.getString('reason');
                await this.reply(`Banned. ${reason ?? ''}`);
            },
            kick: async () => {
                await this.reply('Kicked.');
            }
        });
    }
}

Each arm receives that route's own options, so reason is there in the ban arm even though kick never declares it.

Subcommands work the same way

A route with a slash in it goes in the list like any other. One handler can answer every leaf of a command.

src/handlers/Role.ts
@SlashRoute('role/add', 'role/remove')
export class Role extends SlashHandler<'role/add' | 'role/remove'> {
    public async execute(): Promise<void> {
        await this.match({
            'role/add': (options) =>
                this.reply(`Gave ${options.getRole('role').name}.`),
            'role/remove': (options) =>
                this.reply(`Took ${options.getRole('role').name}.`)
        });
    }
}

Options every route shares

this.options on the class holds the options every route in the list declares. ban and kick both take target, so it's there.

const target = this.options.getUser('target');
const target: User

An option only one route declares stays out.

@SlashRoute('ban', 'kick')
class Moderation extends SlashHandler<'ban' | 'kick'> {
    public async execute(): Promise<void> {
        const reason = this.options.getString('reason');
Property 'getString' does not exist on type 'SlashOptions<"ban" | "kick", "cached">'.
} }

reason belongs to ban alone, so you read it inside that arm.

Both lists must carry the same routes

Adding a route to one list and forgetting the other is a compile error on the decorator line. TypeScript opens that error on a generic line about the decorator, and the sentence you want sits a couple of lines further in:

SlashHandler declares a route that the SlashRoute decorator does not list

Most seedcord type errors carry a plain sentence like that one somewhere in the type noise. Read past the first line and look for it.

@SlashRoute('ban')
Unable to resolve signature of class decorator when called as an expression. Argument of type 'typeof Moderation' is not assignable to parameter of type 'Constructor<["SlashHandler declares a route that the SlashRoute decorator does not list", "ban" | "kick"]>'. Types of construct signatures are incompatible. Type 'new (event: ChatInputCommandInteraction<"cached">, core: Core, dispatch: DispatchContext) => Moderation' is not assignable to type 'new (...arguments_: any[]) => ["SlashHandler declares a route that the SlashRoute decorator does not list", "ban" | "kick"]'. Type 'Moderation' is not assignable to type '["SlashHandler declares a route that the SlashRoute decorator does not list", "ban" | "kick"]'.
class Moderation extends SlashHandler<'ban' | 'kick'> { public async execute(): Promise<void> {} }

Every route needs an arm

Your editor lists the routes still waiting for one.

await this.match({ '
  • ban
  • kick

Leaving one out is a compile error.

@SlashRoute('ban', 'kick')
class Moderation extends SlashHandler<'ban' | 'kick'> {
    public async execute(): Promise<void> {
        await this.match({
Argument of type '{ ban: () => Promise<void>; }' is not assignable to parameter of type 'SlashMatchArms<"ban" | "kick", "cached", void>'. Property 'kick' is missing in type '{ ban: () => Promise<void>; }' but required in type 'SlashMatchArms<"ban" | "kick", "cached", void>'.
ban: async () => { await this.reply('Banned.'); } }); } }