Spraxium logoSpraxium

Handlers and Parameter Injection

Implement slash command logic in handler classes with full dependency injection. Learn how @SlashCommandHandler, @Ctx, @SlashOption, and the nine typed option decorators wire interaction data into method parameters automatically.

The handler class

A handler class is a standalone class decorated with @SlashCommandHandler that contains the execution logic for a slash command. The decorator receives the command definition class as its first argument, creating a metadata link between the schema and the logic. The handler class is instantiated through the DI container during module loading, so constructor parameters are resolved automatically from the module providers.

The handler must expose a handle method. When an interaction arrives that matches the command name (and optionally the subcommand and group), the SlashInvoker calls this method after the guard pipeline passes. The parameter list of handle is built dynamically from metadata: @Ctx() injects the raw interaction, option decorators inject resolved option values, and any parameter without a decorator receives undefined.

handlers/ping.handler.ts
import { Ctx, SlashCommandHandler, SlashStringOption } from '@spraxium/common';
import type { ChatInputCommandInteraction } from 'discord.js';
import { PingCommand } from '../commands/ping.command';

@SlashCommandHandler(PingCommand)
export class PingHandler {
  async handle(
    @Ctx() interaction: ChatInputCommandInteraction,
    @SlashStringOption('service') service: string | null,
  ): Promise<void> {
    const ws = interaction.client.ws.ping;
    if (service) {
      await interaction.reply(`Service: ${service}, Latency: ${ws}ms`);
    } else {
      await interaction.reply(`Pong! ${ws}ms`);
    }
  }
}

Spraxium exports a SlashContext type from @spraxium/common that is a direct alias for ChatInputCommandInteraction. Importing from @spraxium/common reduces the direct coupling to discord.js in your handler files. Both types are interchangeable; use whichever path fits your team's import organization.

The @SlashCommandHandler decorator

The @SlashCommandHandler decorator accepts one required argument and one optional argument. The first argument is the @SlashCommand-decorated class that this handler belongs to. The second argument is an options object with sub and group properties for routing to specific subcommands.

ArgumentTypeRequiredDescription
commandClass constructorYes

The @SlashCommand-decorated class this handler executes for.

options.substringNo

The subcommand name this handler responds to. Omit for root command handlers.

options.groupstringNo

The subcommand group name this handler responds to. Must be used with sub.

The routing resolution follows a strict matching rule. When the binder receives an interaction, it extracts the command name, optional subcommand, and optional subcommand group from the Discord interaction object. The registry then finds the handler where all three values match exactly. This means a root handler (no sub or group) will never receive subcommand interactions, and a subcommand handler will never receive root interactions.

import { Ctx, SlashCommandHandler } from '@spraxium/common';
import type { ChatInputCommandInteraction } from 'discord.js';
import { InfoCommand } from '../commands/info.command';

@SlashCommandHandler(InfoCommand)
export class InfoHandler {
  async handle(@Ctx() interaction: ChatInputCommandInteraction): Promise<void> {
    const ws = interaction.client.ws.ping;
    await interaction.reply(`Bot latency: ${ws}ms`);
  }
}

Parameter injection: @Ctx and option decorators

The handle method parameters are populated by the SlashInvoker at runtime. @Ctx() injects the raw Discord interaction. Option decorators inject the resolved value for each named option from the interaction. Parameter positions are fully independent, so you can place @Ctx at any index and mix it with option decorators in any order.

@SlashOption (generic)

@SlashOption('name') resolves the option value by looking up the option type in the command definition class at runtime, then calling the correct discord.js getter. Use this when you want a quick injection without specifying the type in the decorator itself, because the type is inferred from the command metadata.

handlers/echo.handler.ts
import { Ctx, SlashCommandHandler, SlashOption } from '@spraxium/common';
import type { ChatInputCommandInteraction } from 'discord.js';
import { EchoCommand } from '../commands/echo.command';

@SlashCommandHandler(EchoCommand)
export class EchoHandler {
  async handle(
    @Ctx() interaction: ChatInputCommandInteraction,
    @SlashOption('message') message: string,
  ): Promise<void> {
    await interaction.reply(message);
  }
}

Typed parameter decorators

Nine typed parameter decorators embed the option type directly in metadata, making handler code self-documenting. The lookup in the command definition class is bypassed, and the dispatcher already knows the Discord option type from the decorator itself. Prefer these over @SlashOption when the option type is known at write time.

DecoratorDiscord typeRuntime value
@SlashStringOption(name)STRINGstring or null
@SlashIntegerOption(name)INTEGERnumber or null
@SlashNumberOption(name)NUMBERnumber or null
@SlashBooleanOption(name)BOOLEANboolean or null
@SlashUserOption(name)USERUser or null
@SlashChannelOption(name)CHANNELGuildBasedChannel or null
@SlashRoleOption(name)ROLERole or null
@SlashMentionableOption(name)MENTIONABLEUser or Role or null
@SlashAttachmentOption(name)ATTACHMENTAttachment or null
handlers/ban.handler.ts
import {
  Ctx,
  SlashCommandHandler,
  SlashIntegerOption,
  SlashStringOption,
  SlashUserOption,
} from '@spraxium/common';
import type { ChatInputCommandInteraction, User } from 'discord.js';
import { BanCommand } from '../commands/ban.command';

@SlashCommandHandler(BanCommand)
export class BanHandler {
  async handle(
    @Ctx() interaction: ChatInputCommandInteraction,
    @SlashUserOption('target') target: User | null,
    @SlashStringOption('reason') reason: string | null,
    @SlashIntegerOption('days') days: number | null,
  ): Promise<void> {
    if (!target) {
      await interaction.reply({ content: 'No target provided.', flags: 'Ephemeral' });
      return;
    }
    await interaction.reply(`Banned **${target.tag}**, reason: ${reason ?? 'none'}`);
  }
}

Decorator names must match option names exactly

The string passed to any option decorator must be identical to the option name declared on the command definition. A mismatch causes the invoker to fail the lookup and inject null. Double-check option names when renaming command options, or import the command class as a type reference to catch mismatches during code review.

@SlashOpt (deprecated)

@SlashOpt is marked @deprecated and will be removed in a future major version. It continues to function identically, so existing code does not break. Migrate to @SlashOption or the appropriate typed decorator at your own pace.

snippet.ts
// Before (deprecated)
@SlashOpt('message') message: string | null

// After generic
@SlashOption('message') message: string

// After typed (preferred)
@SlashStringOption('message') message: string | null

Dependency injection in handlers

Handler classes are instantiated through the SpraxiumContainer, so they participate fully in the framework DI system. You can declare constructor parameters for any provider registered in the module tree. The DI container resolves dependencies recursively, so a handler can depend on services that themselves depend on other services.

handlers/locale.handler.ts
import { Ctx, SlashCommandHandler, SlashStringOption } from '@spraxium/common';
import { I18nService } from '@spraxium/i18n';
import type { ChatInputCommandInteraction } from 'discord.js';
import { LocaleCommand } from '../commands/locale.command';

@SlashCommandHandler(LocaleCommand)
export class LocaleHandler {
  constructor(private readonly i18n: I18nService) {}

  async handle(
    @Ctx() interaction: ChatInputCommandInteraction,
    @SlashStringOption('language') language: string | null,
  ): Promise<void> {
    if (!language || !this.i18n.has(language)) {
      const msg = await this.i18n.tUser(
        interaction.user.id,
        'commands.locale.invalid',
        { available: this.i18n.locales().join(', ') },
      );
      await interaction.reply({ content: msg, flags: 'Ephemeral' });
      return;
    }

    await this.i18n.setUserLocale(interaction.user.id, language);
    const msg = this.i18n.t('commands.locale.set', language, { locale: language });
    await interaction.reply({ content: msg, flags: 'Ephemeral' });
  }
}

Handler patterns

import { Ctx, SlashCommandHandler } from '@spraxium/common';
import type { ChatInputCommandInteraction } from 'discord.js';
import { SecretCommand } from '../commands/secret.command';

@SlashCommandHandler(SecretCommand)
export class SecretHandler {
  async handle(@Ctx() interaction: ChatInputCommandInteraction): Promise<void> {
    await interaction.reply({
      content: 'This message is only visible to you.',
      flags: 'Ephemeral',
    });
  }
}

Design checklist for handlers

Use typed option decorators

Prefer @SlashStringOption, @SlashUserOption, etc. over the generic @SlashOption when the option type is known. Typed decorators make the handler self-documenting and skip the runtime lookup against the command definition class.

Handle null for optional parameters

When a command option has required: false, the handler parameter will be null when the user omits the option. Always type optional parameters as Type | null and check for null before using the value.

Respond within three seconds, or use @Defer / @AutoDefer

Discord requires a response within three seconds. For slow handlers use @Defer (always defers) or @AutoDefer (defers only if the threshold is exceeded) instead of calling deferReply() manually. See Deferred Replies.

Keep handler classes focused

Each handler implements exactly one logical operation. When a command has subcommands, use separate handler classes for each subcommand instead of branching inside a single handler.

Use constructor injection for shared logic

When multiple handlers share logic like validation, formatting, or database access, extract that logic into a service class and inject it through the constructor. The DI container handles the lifecycle.