Discord.js + TypeScript — Ch.1: Your First Bot
Video coming soon
Expected:
Why TypeScript?
Discord.js is written in TypeScript and ships first-class type definitions. This means your editor knows the exact shape of every event, every API response, and every builder method. When you type client.on(", autocomplete shows you all 200+ events. When you misspell one, the compiler catches it before you ever run the code.
This matters because Discord bot bugs tend to be silent. A misspelled event name doesn’t throw an error — the handler simply never fires. With TypeScript, that’s a compile-time error instead of a 2 AM debugging session.
Project Setup
Create a new directory and initialize the project:
mkdir discord-bot && cd discord-bot
npm init -y
npm install discord.js
npm install -D typescript tsx @types/nodeThree dependencies, each with a reason:
- discord.js — the library itself. It handles WebSocket connections, caching, rate limiting, and the entire Discord API surface.
- typescript — the compiler. We need it for type checking, even though we’ll use
tsxto run the code. - tsx — a zero-config TypeScript executor. It lets you run
.tsfiles directly without a separate build step. During development,tsx watch src/index.tsgives you hot reload.
TypeScript Configuration
Create tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"strict": true,
"esModuleInterop": true,
"outDir": "dist",
"rootDir": "src",
"skipLibCheck": true
},
"include": ["src"]
}Two settings deserve explanation:
module: "Node16" — This tells TypeScript to use Node’s native module resolution, which requires file extensions in imports (./commands/ping.js). It feels unusual, but it matches how Node actually resolves modules at runtime. Using "NodeNext" also works — it’s an alias for the latest Node module behavior.
strict: true — Enables all strict type-checking flags at once. The most impactful one for Discord bots is strictNullChecks: it forces you to handle cases where a guild member might be null, a channel might not exist, or an interaction might lack certain properties. These are exactly the edge cases that cause bots to crash in production.
Folder Structure
discord-bot/
├── src/
│ ├── index.ts # Entry point — creates client, loads events
│ ├── commands/
│ │ └── ping.ts # Each command = one file
│ └── events/
│ ├── ready.ts # Fires once on successful connection
│ └── interactionCreate.ts # Routes slash commands
├── tsconfig.json
└── package.jsonThis isn’t arbitrary. Each command lives in its own file because commands are the unit of change in a Discord bot — you add, modify, and remove commands independently. Putting them in separate files means you can work on one command without touching any other code.
How Your Bot Connects to Discord
Before writing any code, you need a mental model of what happens when your bot starts. Most tutorials skip this, which leads to confusion about why things like Intents and the ready event exist.
sequenceDiagram
participant Bot as Your Bot
participant GW as Discord Gateway
participant API as Discord REST API
Bot->>GW: WebSocket connection
GW-->>Bot: HELLO (heartbeat interval)
Bot->>GW: IDENTIFY (token + intents)
GW-->>Bot: READY (bot user, guilds)
loop Every ~41.25s
GW-->>Bot: HEARTBEAT request
Bot->>GW: HEARTBEAT ACK
end
GW-->>Bot: Events (messages, interactions, ...)
Note over Bot,API: Slash commands use REST API separately
Bot->>API: Register slash commands
API-->>Bot: Command definitions stored
Your bot maintains a persistent WebSocket connection to the Discord Gateway. This is not HTTP request-response — it’s a long-lived bidirectional channel. Discord pushes events to your bot in real time through this connection. The bot never polls for updates.
The IDENTIFY step is where Intents matter. You’re telling Discord: “Here’s who I am, and here’s what data I need.”
The Client and Intents
Create src/index.ts:
import { Client, GatewayIntentBits, Collection } from "discord.js";
const client = new Client({
intents: [
// Guilds is the minimum intent — enables slash commands and basic server info
GatewayIntentBits.Guilds,
],
});
// Reads the token from environment variables — never hardcode this
client.login(process.env.DISCORD_TOKEN);The Client is your bot’s connection to Discord. The intents array is the most important configuration decision you’ll make.
What Are Intents?
Intents are filters on the Gateway connection. They control which events Discord sends to your bot. If you don’t request the GuildMessages intent, your bot will never receive message events — Discord simply won’t send them.
This system exists for two reasons:
Privacy. Before April 2022, every bot received every event from every server it was in. A bot that only needed to respond to slash commands still received the full content of every message in every channel. Intents let Discord restrict data flow to what each bot actually needs.
Performance. Discord serves millions of bots. Sending every event to every bot wastes bandwidth on both sides. With intents, a bot that only uses slash commands (which come through the Guilds intent) creates a fraction of the Gateway traffic compared to a bot that reads all messages.
Common Intents and What They Enable
| Intent | Events you receive | When you need it |
|---|---|---|
Guilds | Guild create/update/delete, channel and role changes | Almost always — required for basic bot functionality |
GuildMessages | Message create/update/delete in servers | Only if you read message content |
GuildMembers ⚠️ | Member join/leave/update | User tracking, welcome messages |
MessageContent ⚠️ | Actual text content of messages | Prefix commands, content analysis |
DirectMessages | DM message events | If your bot handles DMs |
The ⚠️ intents are privileged. You must manually enable them in the Discord Developer Portal under your application’s Bot settings. Discord reviews bots that request privileged intents once they reach 100+ servers — this is an intentional friction point to protect user privacy.
For this chapter, we only need Guilds. Slash commands don’t require message content or member data.
What are Gateway Intents in Discord.js?
The Ready Event
Create src/events/ready.ts:
import { Client, Events } from "discord.js";
export default {
// Use the Events enum instead of raw strings for compile-time safety
name: Events.ClientReady,
// once: true means this handler fires only on first connect, not on reconnects
once: true,
// Client<true> guarantees client.user is non-null (bot is logged in)
execute(client: Client<true>) {
console.log(`Logged in as ${client.user.tag}`);
},
};Three things to notice:
Events.ClientReady instead of the string "ready". The Events enum gives you autocomplete and compile-time validation. If Discord.js ever renames an event, TypeScript will flag every usage.
once: true means this handler runs exactly once. The ready event fires when the bot first connects and has received its initial guild data. You don’t want to run setup logic every time the bot reconnects after a network interruption.
Client<true> is a TypeScript generic. Client<true> means “a client that is definitely logged in” — so client.user is guaranteed to be non-null. Without the generic, you’d need client.user! or a null check everywhere.
Creating a Slash Command
Create src/commands/ping.ts:
import {
SlashCommandBuilder,
ChatInputCommandInteraction,
} from "discord.js";
export default {
// SlashCommandBuilder creates the JSON definition Discord needs to display the command
data: new SlashCommandBuilder()
.setName("ping")
.setDescription("Check the bot's latency"),
async execute(interaction: ChatInputCommandInteraction) {
// fetchReply: true returns the sent message so we can measure round-trip time
const sent = await interaction.reply({
content: "Pinging...",
fetchReply: true,
});
// Difference between when Discord received the interaction and when our reply arrived
const latency = sent.createdTimestamp - interaction.createdTimestamp;
// ws.ping is the heartbeat latency to the Gateway, measured by discord.js
await interaction.editReply(
`Pong! Latency: ${latency}ms | API: ${interaction.client.ws.ping}ms`
);
},
};SlashCommandBuilder constructs the command definition that Discord needs. When you set .setName("ping"), Discord stores this and shows it to users with autocomplete, descriptions, and parameter validation — all handled server-side before your bot even sees the interaction.
The execute function receives a ChatInputCommandInteraction. This type is specific to slash commands (as opposed to button clicks or modal submissions, which are different interaction types). TypeScript ensures you don’t accidentally try to access properties that don’t exist on this interaction type.
Why Slash Commands Over Prefix Commands
Before 2021, bots used prefix commands: !ping, !ban @user, etc. Discord deprecated this pattern in favor of slash commands for several reasons:
- Discoverability — Users can type
/and see every available command with descriptions. No more guessing prefixes or reading help text. - Validation — Discord validates command parameters before your bot receives them. A required
userparameter will force the user to select a valid user, eliminating an entire category of parsing bugs. - No MessageContent intent — Prefix commands require reading every message to check for the prefix. Slash commands come through the
interactionCreateevent, which is available with just theGuildsintent. This means less data flowing through the Gateway and no need for a privileged intent.
Why did Discord deprecate prefix commands in favor of slash commands?
Wiring It All Together
Now update src/index.ts to load commands and events dynamically:
import { Client, GatewayIntentBits, Collection, REST, Routes } from "discord.js";
import { readdirSync } from "node:fs";
import { join } from "node:path";
// Module augmentation — adds a "commands" property to the Client type
declare module "discord.js" {
interface Client {
commands: Collection<string, any>;
}
}
const client = new Client({
intents: [GatewayIntentBits.Guilds],
});
client.commands = new Collection();
// Load commands dynamically — the file system is the registry
const commandsPath = join(import.meta.dirname, "commands");
const commandFiles = readdirSync(commandsPath).filter(
(file) => file.endsWith(".ts") || file.endsWith(".js")
);
for (const file of commandFiles) {
// Dynamic import — each command file exports a default object with data + execute
const command = (await import(join(commandsPath, file))).default;
client.commands.set(command.data.name, command);
}
// Load events
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;
// "once" events fire only on first occurrence; "on" events fire every time
if (event.once) {
client.once(event.name, (...args) => event.execute(...args));
} else {
client.on(event.name, (...args) => event.execute(...args));
}
}
// Register slash commands via REST API (separate from the Gateway connection)
const rest = new REST().setToken(process.env.DISCORD_TOKEN!);
const commands = client.commands.map((cmd) => cmd.data.toJSON());
// Guild commands update instantly; global commands take up to 1 hour
await rest.put(
Routes.applicationGuildCommands(
process.env.CLIENT_ID!,
process.env.GUILD_ID!
),
{ body: commands }
);
console.log(`Registered ${commands.length} slash commands`);
client.login(process.env.DISCORD_TOKEN);Two important decisions here:
Dynamic loading with readdirSync + import(). This means adding a new command is just creating a new file in commands/. You never edit index.ts to add a command — the file system is the registry. This pattern scales from 1 command to 100.
Guild commands for development. Slash commands can be registered globally (all servers, takes up to 1 hour to propagate) or per guild (one server, instant). During development, always use guild commands. Switch to global when you deploy to production.
The Interaction Router
Create src/events/interactionCreate.ts:
import { Events, Interaction } from "discord.js";
export default {
name: Events.InteractionCreate,
once: false,
async execute(interaction: Interaction) {
// Type guard — narrows Interaction to ChatInputCommandInteraction
if (!interaction.isChatInputCommand()) return;
const command = interaction.client.commands.get(
interaction.commandName
);
if (!command) {
console.error(`Command ${interaction.commandName} not found`);
return;
}
try {
await command.execute(interaction);
} catch (error) {
console.error(`Error executing ${interaction.commandName}:`, error);
// ephemeral: true makes the error visible only to the user who ran the command
const reply = {
content: "Something went wrong executing this command.",
ephemeral: true,
};
// Can't reply twice — use followUp if a reply or deferReply was already sent
if (interaction.replied || interaction.deferred) {
await interaction.followUp(reply);
} else {
await interaction.reply(reply);
}
}
},
};The interaction.isChatInputCommand() check is a type guard. After this check, TypeScript narrows the type from the broad Interaction to ChatInputCommandInteraction, giving you access to properties like commandName that only exist on slash command interactions. Without this check, you’d get a TypeScript error trying to access interaction.commandName.
The error handler checks interaction.replied || interaction.deferred because you can only reply to an interaction once. If the command already sent a reply before it crashed, we use followUp to send the error message instead.
graph TD
A["Discord Gateway"] -->|"interactionCreate event"| B["interactionCreate.ts"]
B -->|"isChatInputCommand?"| C{"Type Guard"}
C -->|"Yes"| D["Lookup in client.commands"]
C -->|"No (button, modal, etc.)"| E["Return — not handled yet"]
D -->|"Found"| F["command.execute(interaction)"]
D -->|"Not found"| G["Log error"]
F -->|"Success"| H["User sees response"]
F -->|"Error"| I["Catch → reply with error message"]
The 3-Second Deadline
There’s one critical runtime constraint you need to internalize: Discord gives your bot exactly 3 seconds to respond to an interaction. If your bot doesn’t call interaction.reply() or interaction.deferReply() within 3 seconds, the interaction token expires and the user sees “This interaction failed.”
Our /ping command is safe because it responds immediately. But any command that queries a database, calls an external API, or does computation needs to account for this deadline:
async execute(interaction: ChatInputCommandInteraction) {
// Buy time — shows "Bot is thinking..." to the user
await interaction.deferReply();
// Now you have 15 minutes to call editReply()
const data = await someSlowOperation();
await interaction.editReply(`Result: ${data}`);
}deferReply() tells Discord “I received this, I’m working on it.” It shows a loading indicator to the user and extends your response window from 3 seconds to 15 minutes. The cost is UX — the user sees “thinking” instead of an instant response. Use it only when needed.
What happens if your bot takes more than 3 seconds to respond to an interaction?
Running Your Bot
Before running the bot you need three values from the Discord Developer Portal:
- Bot Token — Under Bot → Token. This is your bot’s password. Never commit it to version control.
- Client ID — Under General Information → Application ID.
- Guild ID — Right-click your test server in Discord (with Developer Mode enabled in settings) → Copy Server ID.
Create a .env file (add it to .gitignore immediately):
DISCORD_TOKEN=your-bot-token-here
CLIENT_ID=your-application-id
GUILD_ID=your-test-server-idInstall dotenv and add the import to the top of src/index.ts:
npm install dotenvimport "dotenv/config";Add a start script to package.json:
{
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "tsx src/index.ts"
}
}Invite the bot to your test server using this URL pattern (replace YOUR_CLIENT_ID):
https://discord.com/oauth2/authorize?client_id=YOUR_CLIENT_ID&permissions=0&integration_type=0&scope=bot+applications.commandsRun the bot:
npm run devYou should see Logged in as YourBot#1234 and Registered 1 slash commands. Type /ping in your server — the bot should respond with its latency.
What’s Next
In the next chapter we’ll add structure: a modular event system with typed interfaces, rich embeds with EmbedBuilder, and error handling patterns that keep your bot alive in production. The foundation we built here — TypeScript, Intents, slash commands, and the interaction router — is the skeleton that every production bot needs.
Test Your Knowledge
Try this crossword puzzle with the key concepts from this chapter:
Conclusions
- TypeScript catches silent Discord bot bugs at compile time — misspelled events, missing properties, and null access that would otherwise fail silently at runtime
- Gateway Intents are privacy and performance filters, not permissions — they control which events Discord sends to your bot, and privileged intents require manual approval in the Developer Portal
- Your bot maintains a persistent WebSocket connection to Discord’s Gateway — it’s push-based, not request-response
- Slash commands replaced prefix commands as the standard — they provide discoverability, built-in validation, and don’t require the privileged MessageContent intent
- Discord enforces a hard 3-second deadline on interaction responses — use
deferReply()to extend to 15 minutes for slow operations - Dynamic command loading from the file system means adding a command never requires editing
index.ts— one file per command, the directory is the registry