Discord.js + TypeScript — Cap.2: Command Handler Tipado
Video próximamente
Fecha esperada:
¿Qué Tiene de Malo any?
En el Capítulo 1 logramos un bot funcional con esta declaración:
declare module "discord.js" {
interface Client {
commands: Collection<string, any>;
}
}El any aquí significa que TypeScript deja de verificar todo lo que toca la Collection de comandos. Cuando escribes client.commands.get("ping"), el tipo de retorno es any — sin autocompletado para .data o .execute, sin error si escribes mal el nombre de una propiedad, sin advertencia si pasas el tipo incorrecto de argumento a execute. Podrías escribir command.dat en lugar de command.data y TypeScript lo aceptaría silenciosamente. El bug solo aparece en runtime, cuando un usuario ejecuta el comando y el bot crashea.
Esto anula la razón por la que elegimos TypeScript. En el Capítulo 1, usar any fue un atajo pragmático para poner las cosas a funcionar. Ahora lo reemplazamos con un tipo apropiado que hace que el compilador trabaje para nosotros de nuevo. Cada comando se ajustará a una sola interface, la Collection aplicará esa interface, y TypeScript atrapará comandos malformados antes de que lleguen a un servidor de Discord.
La Interface Command
Crea src/types/command.ts:
import {
SlashCommandBuilder,
SlashCommandSubcommandsOnlyBuilder,
ChatInputCommandInteraction,
} from "discord.js";
export interface Command {
// Tipo unión que cubre comandos simples, con subcomandos y con opciones
data:
| SlashCommandBuilder
| SlashCommandSubcommandsOnlyBuilder
| Omit<SlashCommandBuilder, "addSubcommand" | "addSubcommandGroup">;
execute: (interaction: ChatInputCommandInteraction) => Promise<void>;
// Opcional — será usado por el sistema de cooldowns en el Capítulo 12
cooldown?: number;
}Tres cosas a notar:
El tipo de data es una unión. Un comando simple como /ping usa SlashCommandBuilder. Un comando con subcomandos como /info user usa SlashCommandSubcommandsOnlyBuilder (el tipo que se retorna después de llamar a .addSubcommand()). Y un comando con opciones como /ban user:@alguien retorna Omit<SlashCommandBuilder, "addSubcommand" | "addSubcommandGroup"> porque una vez que agregas opciones, Discord.js te impide agregar también subcomandos. Esta unión cubre los tres casos.
El campo cooldown es opcional. No implementaremos cooldowns hasta el Capítulo 12, pero definir el campo ahora significa que los archivos de comandos pueden declarar un valor de cooldown hoy. Cuando construyamos el sistema de cooldowns después, ningún archivo de comando necesita cambiar — los datos ya están ahí. Este es el beneficio de diseñar interfaces antes de su implementación completa.
Esto es una interface, no una clase. Una interface define cómo se ve un comando. Una clase definiría qué es un comando. Nuestros comandos no comparten comportamiento — comparten forma. Cada comando es simplemente un conjunto de datos (data) y una función (execute). No hay jerarquía de herencia, no hay llamadas a super(), no hay estado compartido. Volveremos a esta decisión de diseño al final del capítulo.
Ahora actualiza la declaración del módulo para usar la interface tipada:
// src/types/command.ts (agregar debajo de la interface Command)
// Augmentación de módulo — TypeScript fusiona esto con la interface Client de discord.js
declare module "discord.js" {
interface Client {
commands: Collection<string, Command>;
}
}Con este cambio, client.commands.get("ping") retorna Command | undefined en lugar de any. TypeScript ahora sabe que command.data es un SlashCommandBuilder, que command.execute recibe un ChatInputCommandInteraction, y que acceder a command.dat es un error de compilación.
Arquitectura de Carga de Comandos
Antes de escribir el loader, este es el flujo desde el sistema de archivos hasta un comando en ejecución:
graph TD
A["Sistema de Archivos<br/>commands/ping.ts<br/>commands/mod/ban.ts"] -->|"readdir + import()"| B["Import Dinámico"]
B -->|"module.default"| C["Validación en Runtime<br/>isValidCommand()"]
C -->|"Válido"| D["Collection<string, Command><br/>client.commands.set()"]
C -->|"Inválido"| E["Log de advertencia<br/>Omitir archivo"]
D --> F["interactionCreate"]
F -->|"commands.get(name)"| G["command.execute(interaction)"]
El sistema de archivos es el registro. Agregar un comando significa crear un archivo. Eliminar uno significa borrar un archivo. No hay archivo de configuración que editar, no hay array que actualizar, no hay decorator que registrar. Este patrón escala desde 5 comandos hasta 500 porque al loader no le importa cuántos archivos existen — simplemente recorre el directorio e importa todo lo que encuentra.
Carga Recursiva de Archivos
El loader del Capítulo 1 lee un directorio plano:
const commandFiles = readdirSync(commandsPath).filter(
(file) => file.endsWith(".ts") || file.endsWith(".js")
);Esto falla en el momento que organizas comandos en subdirectorios. Si creas commands/mod/ban.ts y commands/fun/8ball.ts, el readdirSync plano no los encontrará. A medida que tu bot crece, querrás organización por carpetas:
commands/
├── mod/
│ ├── ban.ts
│ ├── kick.ts
│ └── mute.ts
├── fun/
│ ├── 8ball.ts
│ └── coinflip.ts
└── ping.tsCrea 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 — verifica en runtime que un import dinámico coincida con la interface Command
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 retorna objetos Dirent — evita llamadas extra a stat()
const entries = await readdir(currentDir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(currentDir, entry.name);
// Recursión en subdirectorios (ej: 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;
// Atrapa archivos malformados al arrancar en vez de crashear cuando un usuario los invoque
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;
}Dos decisiones de diseño aquí:
readdir en lugar de readdirSync. La versión síncrona del Capítulo 1 bloquea el event loop mientras lee el sistema de archivos. Para un puñado de archivos no importa, pero es un mal hábito. Usar readdir asíncrono de node:fs/promises mantiene el event loop libre. La opción withFileTypes: true retorna objetos Dirent que nos dicen si cada entrada es un archivo o directorio sin una llamada extra a stat.
La función walk es recursiva. Lee un directorio, itera las entradas y se llama a sí misma para los subdirectorios. Este es el patrón más simple para recorrer carpetas anidadas. Cada llamada procesa un nivel, y la recursión maneja profundidad arbitraria. No hay riesgo práctico de stack overflow — el stack por defecto de Node es de 984 KB, lo que permite aproximadamente ~10,000 llamadas anidadas. Necesitarías un árbol de directorios de más de 7,000 niveles de profundidad para desbordarlo, muy por encima de lo que cualquier sistema de archivos permite (PATH_MAX en Linux es 4,096 bytes).
Validación en Runtime
La función isValidCommand merece una mirada más cercana:
// Type guard — el tipo de retorno "obj is Command" le dice a TypeScript que estreche después de esta verificación
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"
);
}Esto es un type guard — el tipo de retorno obj is Command le dice a TypeScript que si esta función retorna true, el argumento puede ser tratado de forma segura como un Command. Después de la verificación, TypeScript estrecha el tipo automáticamente.
¿Por qué necesitamos esto si ya tenemos la interface Command? Porque los tipos de TypeScript se borran en runtime. Cuando haces import() de un archivo, TypeScript no puede verificar que el objeto exportado coincida con la interface — los tipos ya no existen. Un archivo de comando podría exportar un string simple, o un objeto sin la función execute, o nada en absoluto. Sin la verificación en runtime, el bot crashearía con un error confuso como TypeError: command.execute is not a function en algún lugar profundo del handler de interacciones.
Con el type guard, el loader detecta el problema al arrancar, loguea una advertencia clara y omite el archivo. El bot arranca exitosamente con los comandos válidos. Esto es defensa en profundidad: TypeScript atrapa errores en tiempo de compilación, el type guard los atrapa en runtime.
¿Cuál es el principal problema de usar any en la Collection de comandos?
Separando el Deploy de Comandos
En el Capítulo 1, registrábamos los slash commands dentro de index.ts al arrancar:
// Esto se ejecutaba cada vez que el bot arrancaba
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 }
);Esto funciona pero tiene un problema: re-registra cada comando con el API de Discord cada vez que el bot reinicia. El registro de comandos es una llamada al REST API que le dice a Discord “estos son mis comandos, muéstralos a los usuarios.” Una vez registrados, los comandos persisten en los servidores de Discord — no necesitas re-registrarlos a menos que agregues, elimines o modifiques una definición de comando.
Re-registrar en cada arranque es innecesario y puede alcanzar los rate limits si reinicias frecuentemente durante el desarrollo. La solución es separar el deployment en su propio script.
Crea 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() {
// Reutiliza el mismo loader que el bot — asegura que los comandos desplegados coincidan con los cargados
const commands = await loadCommands(
join(import.meta.dirname, "commands"),
);
// toJSON() convierte instancias de SlashCommandBuilder al JSON crudo que Discord espera
const commandData = commands.map((cmd) => cmd.data.toJSON());
const rest = new REST().setToken(process.env.DISCORD_TOKEN!);
// Guild commands: instantáneos, usar para desarrollo
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: hasta 1 hora de propagación, usar para producción
// const globalResult = await rest.put(
// Routes.applicationCommands(process.env.CLIENT_ID!),
// { body: commandData },
// );
}
deploy().catch(console.error);Agrega el script de deploy a package.json:
{
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "tsx src/index.ts",
"deploy": "tsx src/deploy-commands.ts"
}
}Ahora el deploy de comandos y el arranque del bot son operaciones independientes:
sequenceDiagram
participant Dev as Desarrollador
participant Script as deploy-commands.ts
participant Bot as index.ts
participant API as Discord REST API
participant GW as Gateway de Discord
Note over Dev: Cuando los comandos cambian
Dev->>Script: npm run deploy
Script->>API: PUT /commands (registrar definiciones)
API-->>Script: Comandos almacenados
Note over Dev: Cuando arranca el bot
Dev->>Bot: npm run dev
Bot->>GW: Conexión WebSocket
GW-->>Bot: READY
Note over Bot: Carga comandos en memoria
Note over Bot: No necesita llamadas al REST API
GW-->>Bot: Eventos interactionCreate
Bot->>Bot: Enrutar a command.execute()
Ejecutas npm run deploy una vez cuando agregas o modificas comandos. Ejecutas npm run dev para arrancar el bot. El bot se conecta al Gateway, carga los archivos de comandos en memoria y maneja interacciones — nunca toca el REST API para registro de comandos. Si reinicias el bot diez veces mientras depuras, has hecho cero llamadas innecesarias al API.
Recuerda del Capítulo 1: los guild commands se actualizan instantáneamente, los global commands tardan hasta una hora. Sigue usando guild commands durante el desarrollo. Cuando estés listo para hacer público tu bot, descomenta la sección de deploy global y ejecuta npm run deploy una vez.
El problema del deploy manual. Ahora mismo,
npm run deploydepende de que un humano recuerde ejecutarlo. Para un desarrollador solo esto está bien — agregas un comando, haces deploy, pruebas. Pero en el momento en que dos o más personas trabajan en el mismo bot, el deploy manual se convierte en fuente de bugs: alguien agrega un comando y se le olvida hacer deploy, o dos personas despliegan conjuntos de comandos en conflicto. El punto de quiebre es la coordinación — cada colaborador necesita saber cuándo y qué desplegar, y no hay red de seguridad si lo olvidan.La solución es CI/CD (Integración Continua / Despliegue Continuo) — una práctica donde los cambios de código disparan automáticamente una serie de pasos: ejecutar tests, compilar el proyecto y desplegar. En vez de que una persona ejecute
npm run deploy, un pipeline (una secuencia predefinida de pasos automatizados) lo hace cada vez que el código llega a la rama principal. GitHub Actions es una herramienta para esto — permite definir flujos de trabajo en archivos YAML que GitHub ejecuta en sus propios servidores cuando ocurren eventos como un push o un pull request. Lo configuraremos en un capítulo posterior. Por ahora, solo ten en cuenta que el deploy manual es un paso intermedio, no el estado final.
¿Por qué el registro de comandos debería estar separado del arranque del bot?
index.ts Actualizado
Aquí está el punto de entrada refactorizado. Compara con la versión del Capítulo 1 — el código de registro de comandos desapareció, reemplazado por un flujo de arranque limpio:
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],
});
// Collection tipada — no más "any", TypeScript aplica la interface Command
client.commands = new Collection<string, Command>();
// El loader recursivo maneja subdirectorios anidados y valida cada archivo
const commands = await loadCommands(
join(import.meta.dirname, "commands"),
);
client.commands = commands;
// Cargar eventos (se refactorizará a un loader tipado en el Capítulo 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));
}
}
// Sin llamadas al REST API — el registro de comandos ahora lo maneja deploy-commands.ts
client.login(process.env.DISCORD_TOKEN);Qué cambió respecto al Capítulo 1:
- Sin import de
RESTniRoutes. El registro de comandos desapareció del flujo de arranque por completo. loadCommands()reemplaza la carga inline. El loader recursivo maneja subdirectorios y valida cada archivo.Collection<string, Command>en lugar deCollection<string, any>. La declaración del módulo desdetypes/command.tshace que esto funcione — TypeScript sabe qué hay en la Collection.- El bot hace tres cosas al arrancar: crear el client, cargar comandos y eventos, loguearse. Nada más.
El loader de eventos aún usa el readdirSync plano del Capítulo 1. Refactorizaremos los eventos en un sistema modular en el Capítulo 3, con la misma carga recursiva e interfaces tipadas.
Composición vs Clases
Algunos frameworks de bots de Discord usan un patrón basado en clases:
// Estilo framework — NO es lo que estamos haciendo
class PingCommand extends BaseCommand {
constructor() {
super({
name: "ping",
description: "Verificar latencia",
});
}
async execute(interaction: ChatInputCommandInteraction) {
await interaction.reply("Pong!");
}
}Esto se ve limpio, pero la herencia agrega complejidad sin resolver un problema real para bots de Discord. Los comandos no comparten comportamiento — ping y ban no tienen nada en común más allá del hecho de que ambos tienen una propiedad data y una función execute. Esa forma compartida es exactamente lo que una interface describe.
Con una jerarquía de clases, surgen preguntas que no tienen buenas respuestas: ¿Qué va en BaseCommand? ¿Qué pasa si dos comandos comparten una utilidad que un tercero no necesita — agregas otra capa de herencia? ¿Qué pasa si un comando necesita sobreescribir parte del comportamiento base pero no todo? Estas preguntas no surgen con composición porque no hay base que compartir o sobreescribir. Cada comando es un objeto independiente que satisface la interface Command.
El beneficio práctico se muestra cuando agregas funcionalidades. En el Capítulo 12, agregaremos cooldowns — eso es solo un campo cooldown en la interface. En el Capítulo 11, agregaremos verificaciones de permisos — eso es un campo permissions. En el Capítulo 14, los subcomandos son simplemente comandos cuyo data usa .addSubcommand(). Ninguna de estas funcionalidades requiere tocar la “base” del comando porque no hay base. Cada funcionalidad es un campo opcional al que los comandos optan al declararlo.
Una interface define cómo se ve un comando. Una clase define qué es un comando. Cuando las cosas que estás modelando son solo datos y una función, una interface es la herramienta correcta.
¿Por qué el patrón { data, execute } funciona mejor que la herencia de clases para comandos de bots de Discord?
Estructura de Carpetas Actualizada
Así queda el proyecto después de este capítulo:
discord-bot/
├── src/
│ ├── index.ts # Punto de entrada — crea client, carga comandos + eventos, login
│ ├── deploy-commands.ts # Script independiente — registra comandos con el API de Discord
│ ├── types/
│ │ └── command.ts # Interface Command + declaración de módulo
│ ├── utils/
│ │ └── loadCommands.ts # Loader recursivo con validación en runtime
│ ├── commands/
│ │ └── ping.ts # Igual que Cap.1 — sin cambios necesarios
│ └── events/
│ ├── ready.ts # Igual que Cap.1
│ └── interactionCreate.ts # Igual que Cap.1
├── tsconfig.json
├── .env
└── package.jsonLos archivos de comandos del Capítulo 1 no necesitan ninguna modificación. ping.ts ya exporta un objeto con data y execute — ya satisface la interface Command. Este es el beneficio de usar composición: el código existente se ajusta al nuevo sistema de tipos sin ser reescrito.
Lo Que Sigue
En el Capítulo 3 aplicaremos los mismos patrones al sistema de eventos: una interface Event tipada, un loader recursivo de eventos y presencia del bot con ActivityType. También limpiaremos el handler de interactionCreate para soportar interacciones de autocomplete junto con slash commands — sentando las bases para los componentes interactivos que construiremos en capítulos posteriores.
Pon a Prueba tus Conocimientos
Intenta este crucigrama con los conceptos clave de este capítulo:
Conclusiones
- Usar
anyen la Collection de comandos desactiva la verificación de tipos de TypeScript — los typos y propiedades faltantes se convierten en crashes en runtime en lugar de errores de compilación - La interface
Commanddefine la forma que comparten todos los comandos:data(qué registrar con Discord) yexecute(qué ejecutar al invocar), con campos opcionales comocooldownpara funcionalidades futuras - La carga recursiva de archivos con
node:fs/promisespermite organizar comandos en subdirectorios anidados sin cambiar el loader — el sistema de archivos es el registro - La validación en runtime con un type guard (
isValidCommand) atrapa archivos de comandos malformados al arrancar en lugar de crashear cuando un usuario los invoca - El deploy de comandos debería ser un script
npm run deployseparado — los comandos registrados persisten en los servidores de Discord, así que re-registrar en cada reinicio del bot desperdicia llamadas al API y arriesga rate limits - Composición sobre herencia: los comandos no comparten comportamiento, comparten forma — una interface describe esa forma sin la complejidad de jerarquías de clases