Discord Bots

Build a Discord Bot Command Handler Using Native TypeScript Decorators

Tech Setup3 min read
TS

Tech Setup

Published August 16, 2026 · Editorial policy

Build a Discord Bot Command Handler Using Native TypeScript Decorators

Building a Discord bot in TypeScript usually starts clean, but as your command count climbs past twenty, your entry file quickly degrades into an unmaintainable switch-case abyss or an endless maze of recursive directory loaders. If you are a backend developer maintaining a growing Discord application using discord.js, you are likely tired of manually importing every command file, registering metadata, and writing boilerplate execution checks. By leveraging native TypeScript experimental decorators, you can replace runtime path-walking and manual registration with a clean, declarative command handler that routes interactions based entirely on class metadata.

The Architectural Shift: Metadata Over Manual Wiring

You are moving away from this anti-pattern in your index.ts:

import { pingCommand } from './commands/ping';
import { banCommand } from './commands/ban';

client.on('interactionCreate', async interaction => {
  if (!interaction.isChatInputCommand()) return;
  
  if (interaction.commandName === 'ping') {
    await pingCommand.execute(interaction);
  } else if (interaction.commandName === 'ban') {
    await banCommand.execute(interaction);
  }
});

Instead, you want to write a class, slap a @Command decorator on it, and let a central registry discover and register it automatically without explicit imports in your main entry file.

Setting Up the TypeScript Compiler

Decorators are an experimental feature in TypeScript. To use them correctly with metadata reflection, your tsconfig.json requires specific configuration flags.

Create your project directory, initialize it, and install the required dependencies:

mkdir discord-decorators
cd discord-decorators
npm init -y
npm install discord.js@14 reflect-metadata dotenv
npm install -D typescript tsx @types/node
npx tsc --init

Open your generated tsconfig.json and ensure the following compiler options are explicitly enabled under the compilerOptions object:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "lib": ["ES2022"],
    "strict": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "skipLibCheck": true
  }
}
  • experimentalDecorators: Enables the standard TypeScript decorator syntax.
  • emitDecoratorMetadata: Allows reflection-based type-checking and parameter inspection, which we will use to map command option types.
  • NodeNext: Ensures modern ESM module resolution works cleanly with Node.js.

Constructing the Decorator and Registry Engine

You need a storage mechanism for your command metadata. Because decorators execute at file-load time, they can push command definitions into a global array before the Discord client even initializes.

Create a file named src/decorators.ts:

import 'reflect-metadata';
import { SlashCommandBuilder, ChatInputCommandInteraction } from 'discord.js';

export interface CommandOptions {
  builder: SlashCommandBuilder | ((sub: any) => any);
}

// Global registry to store instantiated command metadata
export const COMMAND_REGISTRY: {
  new (...args: any[]): ICommand;
}[] = [];

export interface ICommand {
  execute(interaction: ChatInputCommandInteraction): Promise<void>;
}

export function Command(options: CommandOptions): ClassDecorator {
  return function (target: Function) {
    // Store the constructor in our registry array
    COMMAND_REGISTRY.push(target as any);
    
    // Attach the builder metadata to the class itself
    Reflect.defineMetadata('command:builder', options.builder, target);
  };
}

Implementing the Command Loader and Dispatcher

Next, you need a utility that reads the COMMAND_REGISTRY, instantiates each class, registers the payload with Discord's REST API, and routes incoming interactions to the correct class instance.

Create src/handler.ts:

import { 
  Client, 
  REST, 
  Routes, 
  ChatInputCommandInteraction, 
  SlashCommandBuilder 
} from 'discord.js';
import { COMMAND_REGISTRY, ICommand } from './decorators.js';

export class CommandHandler {
  private commands = new Map<string, ICommand>();
  private builders: any[] = [];

  constructor(private client: Client) {}

  public registerCommands() {
    for (const CommandClass of COMMAND_REGISTRY) {
      const builder = Reflect.getMetadata('command:builder', CommandClass);
      
      if (!builder) {
        console.warn(`[Handler] Class ${CommandClass.name} is missing a @Command decorator.`);
        continue;
      }

      // Instantiate the command class
      const instance = new CommandClass() as ICommand;
      const commandName = builder instanceof SlashCommandBuilder 
        ? builder.name 
        : builder.name; // handles builder functions if used

      this.commands.set(commandName, instance);
      this.builders.push(builder instanceof SlashCommandBuilder ? builder.toJSON() : builder);
      
      console.log(`[Handler] Loaded command: ${commandName}`);
    }
  }

  public async deployToDiscord(token: string, clientId: string, guildId?: string) {
    const rest = new REST({ version: '10' }).setToken(token);

    try {
      console.log('[Handler] Started refreshing application (/) commands.');

      if (guildId) {
        await rest.put(
          Routes.applicationGuildCommands(clientId, guildId),
          { body: this.builders },
        );
        console.log('[Handler] Successfully reloaded guild application (/) commands.');
      } else {
        await rest.put(
          Routes.applicationCommands(clientId),
          { body: this.builders },
        );
        console.log('[Handler] Successfully reloaded global application (/) commands.');
      }
    } catch (error) {
      console.error(error);
    }
  }

  public listen() {
    this.client.on('interactionCreate', async (interaction) => {
      if (!interaction.isChatInputCommand()) return;

      const command = this.commands.get(interaction.commandName);
      if (!command) return;

      try {
        await command.execute(interaction);
      } catch (error) {
        console.error(`[Handler] Error executing ${interaction.commandName}:`, error);
        
        const errorMessage = { content: 'There was an error executing this command!', ephemeral: true };
        if (interaction.replied || interaction.deferred) {
          await interaction.followUp(errorMessage);
        } else {
          await interaction.reply(errorMessage);
        }
      }
    });
  }
}

Writing Decorator-Driven Commands

Now that your infrastructure is built, writing a command is strictly isolated to a single file. You define your Discord slash command builder, implement the ICommand interface, and add your @Command decorator.

Create src/commands/ping.ts:

import { SlashCommandBuilder, ChatInputCommandInteraction } from 'discord.js';
import { Command, ICommand } from '../decorators.js';

@Command({
  builder: new SlashCommandBuilder()
    .setName('ping')
    .setDescription('Replies with Pong and latency!'),
})
export class PingCommand implements ICommand {
  async execute(interaction: ChatInputCommandInteraction): Promise<void> {
    const sent = await interaction.reply({ content: 'Pinging...', fetchReply: true });
    const latency = sent.createdTimestamp - interaction.createdTimestamp;
    
    await interaction.editReply(`Pong! Latency: ${latency}ms. API Latency: ${Math.round(interaction.client.ws.ping)}ms`);
  }
}

Create a second command to demonstrate parameters: src/commands/echo.ts:

import { SlashCommandBuilder, ChatInputCommandInteraction } from 'discord.js';
import { Command, ICommand } from '../decorators.js';

@Command({
  builder: new SlashCommandBuilder()
    .setName('echo')
    .setDescription('Echoes your input back to you')
    .addStringOption(option =>
      option
        .setName('message')
        .setDescription('The message to echo')
        .setRequired(true)
    ),
})
export class EchoCommand implements ICommand {
  async execute(interaction: ChatInputCommandInteraction): Promise<void> {
    const message = interaction.options.getString('message', true);
    await interaction.reply(`Echo: ${message}`);
  }
}

Bootstrapping the Application

Your entry point (index.ts) must import your command files before the handler registers them. If you do not import the files, the JavaScript engine never evaluates them, meaning the @Command decorator never fires and the COMMAND_REGISTRY remains empty.

Create src/index.ts:

import 'reflect-metadata';
import { Client, GatewayIntentBits } from 'discord.js';
import { config } from 'dotenv';
import { CommandHandler } from './handler.js';

// Import all commands to trigger decorator execution and populate the registry
import './commands/ping.js';
import './commands/echo.js';

config();

const token = process.env.DISCORD_TOKEN;
const clientId = process.env.DISCORD_CLIENT_ID;
const guildId = process.env.DISCORD_GUILD_ID; // Optional: for instant guild registration

if (!token || !clientId) {
  console.error('Missing DISCORD_TOKEN or DISCORD_CLIENT_ID in environment variables.');
  process.exit(1);
}

const client = new Client({
  intents: [GatewayIntentBits.Guilds],
});

const handler = new CommandHandler(client);

client.once('ready', async () => {
  console.log(`[Client] Logged in as ${client.user?.tag}`);
  
  // Register commands internally in memory
  handler.registerCommands();
  
  // Deploy commands to Discord API
  await handler.deployToDiscord(token, clientId, guildId);
  
  // Start listening for interactions
  handler.listen();
});

client.login(token);

Create your .env file at the root of your project:

DISCORD_TOKEN=your_bot_token_here
DISCORD_CLIENT_ID=your_application_client_id_here
DISCORD_GUILD_ID=your_development_guild_id_here

Run your bot in development mode using tsx:

npx tsx src/index.ts

Expected terminal output:

[Client] Logged in as MyBot#1234
[Handler] Loaded command: ping
[Handler] Loaded command: echo
[Handler] Started refreshing application (/) commands.
[Handler] Successfully reloaded guild application (/) commands.

Troubleshooting Common Decorator Pitfalls

1. Commands Are Registered as Empty or Missing

Symptom: The bot starts, but typing /ping yields "Application did not respond" or the command does not appear in Discord. Cause: You forgot to import the command file inside index.ts. Because the file is never imported, Node.js never evaluates the class, and the decorator function inside decorators.ts never executes to populate COMMAND_REGISTRY. Fix: Explicitly import every command file at the top of src/index.ts. If you want automatic directory scanning without manual imports, use fs.readdirSync or a glob importer to dynamically require/import all files in your commands/ directory before calling registerCommands().

2. TypeScript Error: Experimental support for decorators is a feature that is subject to change

Symptom: The compiler throws a warning or outright blocks compilation regarding experimental decorators. Cause: Your tsconfig.json is missing the experimentalDecorators flag. Fix: Open tsconfig.json and ensure "experimentalDecorators": true and "emitDecoratorMetadata": true are explicitly set inside compilerOptions.

3. Metadata Returns undefined During Class Instantiation

Symptom: Reflect.getMetadata returns undefined for command:builder. Cause: The reflect-metadata package was not imported at the absolute entry point of your application, or TypeScript's target does not align with your runtime environment. Fix: Ensure import 'reflect-metadata'; is the very first line of your src/index.ts file and inside your src/decorators.ts file.