Discord.js + TypeScript — Ch.2: Typed Command Handler

typescript tutorial discord

Video coming soon

Expected:

What’s Wrong with any

In Chapter 1 we got a working bot with this declaration:

declare module "discord.js" {
  interface Client {
    commands: Collection<string, any>;
  }
}

The any here means TypeScript stops checking everything that touches the command Collection. When you write client.commands.get("ping"), the return type is any — no autocomplete for .data or .execute, no error if you misspell a property name, no warning if you pass the wrong argument type to execute. You could write command.dat instead of command.data and TypeScript would silently accept it. The bug only surfaces at runtime, when a user runs the command and the bot crashes.

This defeats the entire reason we chose TypeScript. In Chapter 1, using any was a pragmatic shortcut to get things running. Now we replace it with a proper type that makes the compiler work for us again. Every command will conform to a single interface, the Collection will enforce that interface, and TypeScript will catch malformed commands before they ever reach a Discord server.

The Command Interface

Create src/types/command.ts:

import {
  SlashCommandBuilder,
  SlashCommandSubcommandsOnlyBuilder,
  ChatInputCommandInteraction,
} from "discord.js";

export interface Command {
  // Union type covers plain commands, commands with subcommands, and commands with options
  data:
    | SlashCommandBuilder
    | SlashCommandSubcommandsOnlyBuilder
    | Omit<SlashCommandBuilder, "addSubcommand" | "addSubcommandGroup">;
  execute: (interaction: ChatInputCommandInteraction) => Promise<void>;
  // Optional — will be used by the cooldown system in Chapter 12
  cooldown?: number;
}

Three things to notice:

The data type is a union. A plain command like /ping uses SlashCommandBuilder. A command with subcommands like /info user uses SlashCommandSubcommandsOnlyBuilder (the type returned after calling .addSubcommand()). And a command with options like /ban user:@someone returns Omit<SlashCommandBuilder, "addSubcommand" | "addSubcommandGroup"> because once you add options, Discord.js prevents you from also adding subcommands. This union covers all three cases.

The cooldown field is optional. We won’t implement cooldowns until Chapter 12, but defining the field now means command files can declare a cooldown value today. When we build the cooldown system later, no command files need to change — the data is already there. This is the benefit of designing interfaces ahead of their full implementation.

This is an interface, not a class. An interface defines what a command looks like. A class would define what a command is. Our commands don’t share behavior — they share shape. Each command is just a bag of data (data) and a function (execute). There’s no inheritance hierarchy, no super() calls, no shared state. We’ll return to this design choice at the end of the chapter.

Now update the module declaration to use the typed interface:

// src/types/command.ts (add below the Command interface)
// Module augmentation — TypeScript merges this with discord.js's own Client interface
declare module "discord.js" {
  interface Client {
    commands: Collection<string, Command>;
  }
}

With this change, client.commands.get("ping") returns Command | undefined instead of any. TypeScript now knows that command.data is a SlashCommandBuilder, that command.execute takes a ChatInputCommandInteraction, and that accessing command.dat is a compile-time error.

Command Loading Architecture

Before writing the loader, here’s the flow from file system to running command:

graph TD
    A["File System<br/>commands/ping.ts<br/>commands/mod/ban.ts"] -->|"readdir + import()"| B["Dynamic Import"]
    B -->|"module.default"| C["Runtime Validation<br/>isValidCommand()"]
    C -->|"Valid"| D["Collection&lt;string, Command&gt;<br/>client.commands.set()"]
    C -->|"Invalid"| E["Log warning<br/>Skip file"]
    D --> F["interactionCreate"]
    F -->|"commands.get(name)"| G["command.execute(interaction)"]

The file system is the registry. Adding a command means creating a file. Removing one means deleting a file. No configuration file to edit, no array to update, no decorator to register. This pattern scales from 5 commands to 500 because the loader doesn’t care how many files exist — it just walks the directory and imports everything it finds.

Recursive File Loading

The loader from Chapter 1 reads a flat directory:

const commandFiles = readdirSync(commandsPath).filter(
  (file) => file.endsWith(".ts") || file.endsWith(".js")
);

This breaks the moment you organize commands into subdirectories. If you create commands/mod/ban.ts and commands/fun/8ball.ts, the flat readdirSync won’t find them. As your bot grows, you’ll want folder organization:

commands/
├── mod/
│   ├── ban.ts
│   ├── kick.ts
│   └── mute.ts
├── fun/
│   ├── 8ball.ts
│   └── coinflip.ts
└── ping.ts

Create src/utils/loadCommands.ts:

import { readdir } from "node:fs/promises";
import { join } from "node:path";
import { Collection } from "discord.js";
import type { Command } from "../types/command.js";

// Type guard — verifies at runtime that a dynamic import matches the Command interface
function isValidCommand(obj: unknown): obj is Command {
  return (
    typeof obj === "object" &&
    obj !== null &&
    "data" in obj &&
    "execute" in obj &&
    typeof (obj as Command).execute === "function"
  );
}

export async function loadCommands(
  dir: string,
): Promise<Collection<string, Command>> {
  const commands = new Collection<string, Command>();

  async function walk(currentDir: string): Promise<void> {
    // withFileTypes: true returns Dirent objects — avoids extra stat() calls
    const entries = await readdir(currentDir, { withFileTypes: true });

    for (const entry of entries) {
      const fullPath = join(currentDir, entry.name);

      // Recurse into subdirectories (e.g., commands/mod/, commands/fun/)
      if (entry.isDirectory()) {
        await walk(fullPath);
        continue;
      }

      if (!entry.name.endsWith(".ts") && !entry.name.endsWith(".js")) {
        continue;
      }

      const imported = await import(fullPath);
      const command = imported.default;

      // Catch malformed files at startup instead of crashing when a user triggers them
      if (!isValidCommand(command)) {
        console.warn(
          `[WARNING] Skipping ${fullPath}: missing "data" or "execute"`,
        );
        continue;
      }

      commands.set(command.data.name, command);
    }
  }

  await walk(dir);
  console.log(`Loaded ${commands.size} commands`);
  return commands;
}

Two design decisions here:

readdir instead of readdirSync. The synchronous version from Chapter 1 blocks the event loop while reading the file system. For a handful of files this doesn’t matter, but it’s a bad habit. Using async readdir from node:fs/promises keeps the event loop free. The withFileTypes: true option returns Dirent objects that tell us whether each entry is a file or directory without an extra stat call.

The walk function is recursive. It reads a directory, iterates entries, and calls itself for subdirectories. This is the simplest pattern for traversing nested folders. Each call processes one level, and the recursion handles arbitrary depth. There’s no practical stack overflow risk — Node’s default stack is 984 KB, which allows roughly ~10,000 nested calls. You’d need a directory tree over 7,000 levels deep to overflow it, far beyond what any filesystem allows (Linux’s PATH_MAX is 4,096 bytes).

Runtime Validation

The isValidCommand function deserves a closer look:

// Type guard — the "obj is Command" return type tells TypeScript to narrow after this check
function isValidCommand(obj: unknown): obj is Command {
  return (
    typeof obj === "object" &&
    obj !== null &&
    "data" in obj &&
    "execute" in obj &&
    typeof (obj as Command).execute === "function"
  );
}

This is a type guard — the obj is Command return type tells TypeScript that if this function returns true, the argument can be safely treated as a Command. After the check, TypeScript narrows the type automatically.

Why do we need this when we already have the Command interface? Because TypeScript types are erased at runtime. When you import() a file, TypeScript can’t verify that the exported object matches the interface — the types are gone. A command file could export a plain string, or an object missing the execute function, or nothing at all. Without the runtime check, the bot would crash with a confusing error like TypeError: command.execute is not a function somewhere deep in the interaction handler.

With the type guard, the loader catches the problem at startup, logs a clear warning, and skips the file. The bot starts successfully with the valid commands. This is defense in depth: TypeScript catches errors at compile time, the type guard catches them at runtime.

What's the main problem with using any in the command Collection?

Separating Command Deployment

In Chapter 1, we registered slash commands inside index.ts at startup:

// This ran every time the bot started
const rest = new REST().setToken(process.env.DISCORD_TOKEN!);
const commands = client.commands.map((cmd) => cmd.data.toJSON());
await rest.put(
  Routes.applicationGuildCommands(
    process.env.CLIENT_ID!,
    process.env.GUILD_ID!
  ),
  { body: commands }
);

This works but has a problem: it re-registers every command with the Discord API every time the bot restarts. Command registration is a REST API call that tells Discord “here are my commands, display them to users.” Once registered, commands persist in Discord’s servers — you don’t need to re-register them unless you add, remove, or change a command definition.

Re-registering on every boot is wasteful and can hit rate limits if you restart frequently during development. The fix is to separate deployment into its own script.

Create src/deploy-commands.ts:

import "dotenv/config";
import { REST, Routes } from "discord.js";
import { join } from "node:path";
import { loadCommands } from "./utils/loadCommands.js";

async function deploy() {
  // Reuse the same loader the bot uses — ensures deployed commands match loaded commands
  const commands = await loadCommands(
    join(import.meta.dirname, "commands"),
  );

  // toJSON() converts SlashCommandBuilder instances into the raw JSON Discord expects
  const commandData = commands.map((cmd) => cmd.data.toJSON());

  const rest = new REST().setToken(process.env.DISCORD_TOKEN!);

  // Guild commands: instant, use for development
  const guildResult = await rest.put(
    Routes.applicationGuildCommands(
      process.env.CLIENT_ID!,
      process.env.GUILD_ID!,
    ),
    { body: commandData },
  );

  console.log(
    `Deployed ${(guildResult as unknown[]).length} guild commands`,
  );

  // Global commands: up to 1 hour propagation, use for production
  // const globalResult = await rest.put(
  //   Routes.applicationCommands(process.env.CLIENT_ID!),
  //   { body: commandData },
  // );
}

deploy().catch(console.error);

Add the deploy script to package.json:

{
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "start": "tsx src/index.ts",
    "deploy": "tsx src/deploy-commands.ts"
  }
}

Now command deployment and bot startup are independent operations:

sequenceDiagram
    participant Dev as Developer
    participant Script as deploy-commands.ts
    participant Bot as index.ts
    participant API as Discord REST API
    participant GW as Discord Gateway

    Note over Dev: When commands change
    Dev->>Script: npm run deploy
    Script->>API: PUT /commands (register definitions)
    API-->>Script: Commands stored

    Note over Dev: When starting the bot
    Dev->>Bot: npm run dev
    Bot->>GW: WebSocket connection
    GW-->>Bot: READY
    Note over Bot: Loads commands into memory
    Note over Bot: No REST API calls needed
    GW-->>Bot: interactionCreate events
    Bot->>Bot: Route to command.execute()

You run npm run deploy once when you add or modify commands. You run npm run dev to start the bot. The bot connects to the Gateway, loads command files into memory, and handles interactions — it never touches the REST API for command registration. If you restart the bot ten times while debugging, you’ve made zero unnecessary API calls.

Remember from Chapter 1: guild commands update instantly, global commands take up to one hour. Keep using guild commands during development. When you’re ready to make your bot public, uncomment the global deployment section and run npm run deploy once.

The manual deploy problem. Right now, npm run deploy depends on a human remembering to run it. For a solo developer this is fine — you add a command, you deploy, you test. But the moment two or more people work on the same bot, manual deployment becomes a source of bugs: someone adds a command and forgets to deploy, or two people deploy conflicting command sets. The breaking point is coordination — every contributor needs to know when and what to deploy, and there’s no safety net if they forget.

The solution is CI/CD (Continuous Integration / Continuous Deployment) — a practice where code changes automatically trigger a series of steps: run tests, build the project, and deploy. Instead of a person running npm run deploy, a pipeline (a predefined sequence of automated steps) does it every time code reaches the main branch. GitHub Actions is one tool for this — it lets you define workflows in YAML files that GitHub runs on its own servers when events like a push or pull request occur. We’ll set this up in a later chapter. For now, just be aware that manual deployment is a stepping stone, not the end state.

Why should command registration be separate from bot startup?

Updated index.ts

Here’s the refactored entry point. Compare this with the Chapter 1 version — the command registration code is gone, replaced by a clean startup flow:

import "dotenv/config";
import { Client, GatewayIntentBits, Collection } from "discord.js";
import { join } from "node:path";
import { loadCommands } from "./utils/loadCommands.js";
import type { Command } from "./types/command.js";
import { readdirSync } from "node:fs";

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

// Typed Collection — no more "any", TypeScript enforces the Command interface
client.commands = new Collection<string, Command>();

// Recursive loader handles nested subdirectories and validates each file
const commands = await loadCommands(
  join(import.meta.dirname, "commands"),
);
client.commands = commands;

// Load events (will be refactored to a typed loader in Chapter 3)
const eventsPath = join(import.meta.dirname, "events");
const eventFiles = readdirSync(eventsPath).filter(
  (file) => file.endsWith(".ts") || file.endsWith(".js"),
);

for (const file of eventFiles) {
  const event = (await import(join(eventsPath, file))).default;
  if (event.once) {
    client.once(event.name, (...args: unknown[]) => event.execute(...args));
  } else {
    client.on(event.name, (...args: unknown[]) => event.execute(...args));
  }
}

// No REST API calls — command registration is now handled by deploy-commands.ts
client.login(process.env.DISCORD_TOKEN);

What changed from Chapter 1:

  • No REST or Routes import. Command registration is gone from the startup path entirely.
  • loadCommands() replaces inline loading. The recursive loader handles subdirectories and validates each file.
  • Collection<string, Command> instead of Collection<string, any>. The module declaration from types/command.ts makes this work — TypeScript knows what’s in the Collection.
  • The bot does three things at startup: create the client, load commands and events, log in. Nothing else.

The event loader still uses the flat readdirSync from Chapter 1. We’ll refactor events into a modular system in Chapter 3, with the same recursive loading and typed interfaces.

Composition vs Classes

Some Discord bot frameworks use a class-based pattern:

// Framework-style — NOT what we're doing
class PingCommand extends BaseCommand {
  constructor() {
    super({
      name: "ping",
      description: "Check latency",
    });
  }

  async execute(interaction: ChatInputCommandInteraction) {
    await interaction.reply("Pong!");
  }
}

This looks clean, but inheritance adds complexity without solving a real problem for Discord bots. Commands don’t share behavior — ping and ban have nothing in common besides the fact that they both have a data property and an execute function. That shared shape is exactly what an interface describes.

With a class hierarchy, you get questions that have no good answers: What goes in BaseCommand? What if two commands share a utility that a third doesn’t need — do you add another layer of inheritance? What if a command needs to override part of the base behavior but not all of it? These questions don’t arise with composition because there’s no base to share or override. Each command is a standalone object that happens to satisfy the Command interface.

The practical benefit shows up when you add features. In Chapter 12, we’ll add cooldowns — that’s just a cooldown field on the interface. In Chapter 11, we’ll add permission checks — that’s a permissions field. In Chapter 14, subcommands are just commands whose data uses .addSubcommand(). None of these features require touching the command “base” because there is no base. Each feature is an optional field that commands opt into by declaring it.

An interface defines what a command looks like. A class defines what a command is. When the things you’re modeling are just data and a function, an interface is the right tool.

Why does the { data, execute } pattern work better than class inheritance for Discord bot commands?

Updated Folder Structure

Here’s where the project stands after this chapter:

discord-bot/
├── src/
│   ├── index.ts                # Entry point — creates client, loads commands + events, logs in
│   ├── deploy-commands.ts      # Standalone script — registers commands with Discord API
│   ├── types/
│   │   └── command.ts          # Command interface + module declaration
│   ├── utils/
│   │   └── loadCommands.ts     # Recursive file loader with runtime validation
│   ├── commands/
│   │   └── ping.ts             # Same as Ch.1 — no changes needed
│   └── events/
│       ├── ready.ts            # Same as Ch.1
│       └── interactionCreate.ts  # Same as Ch.1
├── tsconfig.json
├── .env
└── package.json

The command files from Chapter 1 don’t need any modifications. ping.ts already exports an object with data and execute — it already satisfies the Command interface. This is the payoff of using composition: existing code conforms to the new type system without being rewritten.

What’s Next

In Chapter 3 we’ll apply the same patterns to the event system: a typed Event interface, a recursive event loader, and rich bot presence with ActivityType. We’ll also clean up the interactionCreate handler to support autocomplete interactions alongside slash commands — laying the groundwork for the interactive components we’ll build in later chapters.

Test Your Knowledge

Try this crossword puzzle with the key concepts from this chapter:

Conclusions

  • Using any in the command Collection disables TypeScript’s type checking — typos and missing properties become runtime crashes instead of compile-time errors
  • The Command interface defines the shape all commands share: data (what to register with Discord) and execute (what to run when invoked), with optional fields like cooldown for future features
  • Recursive file loading with node:fs/promises lets you organize commands into nested subdirectories without changing the loader — the file system is the registry
  • Runtime validation with a type guard (isValidCommand) catches malformed command files at startup instead of crashing when a user triggers them
  • Command deployment should be a separate npm run deploy script — registered commands persist in Discord’s servers, so re-registering on every bot restart wastes API calls and risks rate limits
  • Composition over inheritance: commands don’t share behavior, they share shape — an interface describes that shape without the complexity of class hierarchies