Writing a discord bot TypeScript project pays off the moment it starts to grow: your editor knows the autocomplete, it catches an argument of the wrong type before you ever run the code, and during a big refactor it tells you exactly which files broke at compile time. Since discord.js v14 is itself written entirely in TypeScript, using TS means you get the library's full type information for free. In this guide we'll build a type-safe discord.js project from scratch, step by step, that compiles and runs.
Why TypeScript?
A bot written in plain JavaScript causes no trouble while it's small, but once you pass 20-30 commands, a few events and a database layer, silent bugs start piling up. TypeScript surfaces most of them before you even hit save:
- Autocomplete: Type
interaction.and the editor lists every valid method and property — no more constantly opening the docs. - Early error detection: Accessing a non-existent field or passing the wrong type to a function fails at compile time, not in front of a user.
- Safe refactoring: Change a command interface and every file that uses it is flagged by the compiler.
- Self-documenting code: Types double as live documentation; your teammates can read what each piece expects.
Bootstrapping the project
First we create an empty folder and install the required packages. discord.js is a runtime dependency, while TypeScript and the type packages are only needed for development.
npm init -y
npm install discord.js
npm install -D typescript @types/node tsx
Here tsx is a fast tool that lets us run TypeScript files directly without a separate build step; during development it's more convenient than ts-node. For production we'll do a real tsc build.
Configuring tsconfig.json
The heart of type safety is the tsconfig.json file. A solid, strict starting point for a modern Node.js bot looks like this:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"]
}
The most critical line is "strict": true; it enables null checks, the ban on implicit any, and more in one go. This flag is where you get TypeScript's full value — turning it off is like driving with the handbrake on.
The first connection and a typed Client
Let the bot's entry point be src/index.ts. discord.js's Client class is already fully typed, so if you try to pass an invalid value into the intents array the editor warns you instantly.
// src/index.ts
import { Client, GatewayIntentBits, Events } from 'discord.js';
const client = new Client({
intents: [GatewayIntentBits.Guilds],
});
client.once(Events.ClientReady, (c) => {
console.log(`Logged in as: ${c.user.tag}`);
});
client.login(process.env.DISCORD_TOKEN);
Here the type of the c parameter is automatically inferred as Client<true>, so TypeScript guarantees that c.user is not null and you can safely access .tag.
Defining an interface for commands
Every Slash command must follow the same shape so the handler can treat them uniformly. We lock this into a contract with an interface:
// src/types.ts
import {
ChatInputCommandInteraction,
SlashCommandBuilder,
SlashCommandOptionsOnlyBuilder,
} from 'discord.js';
export interface Command {
data: SlashCommandBuilder | SlashCommandOptionsOnlyBuilder;
execute: (interaction: ChatInputCommandInteraction) => Promise<void>;
}
Now every command file must implement this Command type. If you forget to write the execute function in a command or use the wrong parameter type, you'll see the red underline the instant you save the file.
// src/commands/ping.ts
import { SlashCommandBuilder } from 'discord.js';
import type { Command } from '../types.js';
export const ping: Command = {
data: new SlashCommandBuilder()
.setName('ping')
.setDescription('Measures the bot latency'),
async execute(interaction) {
await interaction.reply(`Pong! ${interaction.client.ws.ping}ms`);
},
};
Extending the Client: declaration merging
We want to store commands in a collection like client.commands, but discord.js's Client type has no such property. TypeScript's declaration merging is exactly what solves this: we extend the library's type definition with our own field.
// src/types.ts (continued)
import { Collection } from 'discord.js';
declare module 'discord.js' {
interface Client {
commands: Collection<string, Command>;
}
}
Thanks to this block, when you write client.commands.get('ping') the editor knows the correct type; you don't need to use a fake any or cast on every access.
Loading and running commands
When starting the bot we fill the collection with command files, then route each incoming interaction to the right command. Catching errors centrally inside interactionCreate is the healthiest approach.
// src/index.ts (additions)
import { Collection } from 'discord.js';
import { ping } from './commands/ping.js';
client.commands = new Collection();
client.commands.set(ping.data.name, ping);
client.on(Events.InteractionCreate, async (interaction) => {
if (!interaction.isChatInputCommand()) return;
const command = client.commands.get(interaction.commandName);
if (!command) return;
try {
await command.execute(interaction);
} catch (err) {
console.error(err);
await interaction.reply({
content: 'There was an error while running this command.',
ephemeral: true,
});
}
});
The interaction.isChatInputCommand() check is a type guard: after that line TypeScript narrows interaction to ChatInputCommandInteraction, so you can safely access fields like commandName and reply.
Development and production scripts
The final step is adding two scripts to package.json: instant running with tsx for development, and compiling to the dist/ folder with tsc for production.
{
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
}
}
While developing, npm run dev watches for file changes and restarts the bot automatically. When shipping to a server you produce plain JavaScript with npm run build and run it with npm start — no TypeScript is needed at runtime in production.
Frequently Asked Questions
Should I use ESM or CommonJS?
ESM with "type": "module" is recommended for new projects; discord.js v14 supports both. When using ESM, remember that you must write the .js extension on local file imports (e.g. './types.js') — this is required for the compiled output to resolve correctly.
Why tsx instead of ts-node?
tsx is esbuild-based, so it starts far faster and needs almost zero configuration for ESM. It does not type-check, it only runs; that's why using tsx for speed in development and real type checking with tsc at build time is the ideal combination.
Can I run TypeScript directly in production?
Technically possible with tsx, but not recommended. Pre-compiling with tsc in production both speeds up startup and catches type errors at build time, preventing broken code from reaching your server.
Want to put your bot on a type-safe, maintainable foundation? From a from-scratch TypeScript setup to command-handler architecture and deployment, we can move your project onto solid ground together. Get in touch with me and let's plan it around your needs.