Guide
Running a discord.js bot in TypeScript
discord.js ships its own types, so TypeScript works with no extra packages. The real question is how the .ts files get run, because Node.js was built for JavaScript. There are three good answers.
← All guides2 min readUpdated
Set up the project
Install discord.js as usual, and TypeScript with the Node.js types as development dependencies. Do not install a separate @types package for discord.js; its types come with it.
The settings below compile src into dist. With module set to NodeNext, TypeScript follows Node's own rules: if package.json says "type": "module", relative imports must end in .js, even though the file you wrote is .ts. Leaving the extension off is the most common cause of "Cannot find module" in a TypeScript bot.
Terminal
npm install discord.js npm install --save-dev typescript @types/node
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "src",
"outDir": "dist",
"strict": true,
"skipLibCheck": true
},
"include": ["src"]
}Compile with tsc
The most portable way: tsc type-checks your code and writes plain JavaScript into dist, and Node runs that like any other bot. A type error stops the build, so mistakes are caught before they reach a running bot.
The cost is a step between saving a file and running it. Add scripts for both, and remember that dist is what actually runs: a change to src does nothing until you build again.
package.json
"scripts": {
"build": "tsc",
"start": "node dist/index.js"
}Run it with tsx while developing
tsx runs .ts files directly under Node by stripping the types as each file loads, and its watch mode restarts the bot every time you save. It is the quickest loop for development.
It does not check types, only removes them, so code with type errors runs anyway. Run tsc --noEmit now and then, or let your editor show the errors.
Terminal
npx tsx watch src/index.ts # Check types without writing any output. npx tsc --noEmit
Or let Bun run it
Bun runs TypeScript directly, with no build step and no extra package: bun src/index.ts starts the bot. It reads the same package.json, and discord.js runs on it. Like tsx, Bun strips types rather than checking them, so keep tsc --noEmit for catching errors.
Typing your commands
Most command handlers keep each command in its own file and look it up by name. Giving every command the same shape lets TypeScript check them all. The data field is typed by what the handler needs from it, its name and toJSON, because adding options to a SlashCommandBuilder returns a different builder type, and a field typed as SlashCommandBuilder would reject it.
In the interaction handler, isChatInputCommand() narrows the interaction to the slash-command type, so the command receives an interaction with every method it expects.
src/command.ts
import type { ChatInputCommandInteraction, SlashCommandBuilder } from 'discord.js';
export interface Command {
data: Pick<SlashCommandBuilder, 'name' | 'toJSON'>;
execute(interaction: ChatInputCommandInteraction): Promise<void>;
}src/commands/ping.ts
import { SlashCommandBuilder } from 'discord.js';
import type { Command } from '../command.js';
export const ping: Command = {
data: new SlashCommandBuilder().setName('ping').setDescription('Replies with pong'),
async execute(interaction) {
await interaction.reply('pong');
},
};On our hosting
On a Bun server, name your .ts entry file and Bun runs it as it is; package.json is installed on deploy. This is the shortest path from a TypeScript project to a running bot.
On a Node.js server, run npm run build on your own machine, upload the project with its dist folder and without node_modules, and name dist/index.js as the entry file. Either way, your token goes in an environment variable and is read from process.env, as in JavaScript.
FAQ
Questions
Can Node.js run .ts files by itself now?
Recent Node.js releases can, by stripping the types, with limits: only syntax that can simply be erased is allowed, so no enums or namespaces, and relative imports must name the .ts file. tsx and Bun have fewer restrictions.
Why does process.env.DISCORD_TOKEN have the type string | undefined?
Because nothing guarantees the variable is set. Check it once at startup and stop with a clear message if it is missing, rather than letting the login fail with a less helpful error.
Read next