Discord.js + TypeScript — Ch.2: Typed Command Handler
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<string, Command><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.tsCreate 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 deploydepends 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
RESTorRoutesimport. 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 ofCollection<string, any>. The module declaration fromtypes/command.tsmakes 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.jsonThe 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
anyin the command Collection disables TypeScript’s type checking — typos and missing properties become runtime crashes instead of compile-time errors - The
Commandinterface defines the shape all commands share:data(what to register with Discord) andexecute(what to run when invoked), with optional fields likecooldownfor future features - Recursive file loading with
node:fs/promiseslets 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 deployscript — 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