A sane, opinionated template for discord bots written in typescript using the discord.js library. It doesn't rely on transpilation - typescript is ran directly by node.
Note
This template provides a mise configuration to make it easy to keep node and pnpm versions in sync.
Uses, among other tools/packages:
- pnpm as package manager for node
- biome for code linting and formatting
- lefthook for git hooks
- commitlint for commit message linting
- vitest for testingypescript
- envalid for env validation and parsing
- @mkvlrn/result for error handling
If you use mise and run mise install in the project root, you'll have the correct node and pnpm versions installed.
This is by far the easiest way to keep your environment consistent across different machines and team members, no matter the frequency of version updates. I'm not affiliated with mise but I wholeheartedly recommend it, so check it here: https://mise.jdx.dev.
If not using mise, make sure you have:
- node 24+ installed (v24.15.0 used)
- pnpm 10+ installed (v10.33.0 used)
Then, install dependencies with:
pnpm installNote
Git hooks are in place to make sure both the tooling managed by mise and the project dependencies are synced with each checkout and merge.
Subpath imports (#/) are used instead of relative paths, mapped in both package.json and tsconfig.json.
Example:
import { add } from "#/math/basic"; // this points to ./src/math/basic.tsAn untracked, local .env file can be used during development, and you can load it up with the --env-file flag for node.
Create a project there setting the following secrets:
DISCORD_CLIENT_IDDISCORD_CLIENT_TOKENDEV_SERVERLOG_LEVEL
The dev, start, register, and unregister npm scripts use the .env file by default, although you should probably use something else for secret management, such as doppler or others.
This is just a low friction setup.
Runs the project in watch mode.
Runs the built project.
Runs tests with vitest.
Runs biome in fix mode to lint and format the project.
Runs type checking using tsc.
Registers slash commands globally, or to the dev server if --dev flag is provided
Unregisters slash commands globally, or from the dev server if --dev flag is provided
Commands are auto-loaded from ./src/commands/. Just create a file and call createBotCommand.
Note: Discord requires command names to be lowercase. Use kebab-case for multi-word commands (e.g., my-command).
- Create a new file in
./src/commands/(e.g.,my-command.ts) - Call
createBotCommandwith your command definition:
import { SlashCommandBuilder } from "discord.js";
import { createBotCommand } from "#/modules/commands";
createBotCommand({
data: new SlashCommandBuilder().setName("my-command").setDescription("Does something"),
async execute(interaction) {
await interaction.reply("Hello!");
},
});- Run
pnpm registerto register commands globally (orpnpm register --devfor your dev server) - Restart your bot
For commands with buttons, select menus, or modals, add a followUp handler. Use a prefix in customId to route interactions back to your command:
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, SlashCommandBuilder } from "discord.js";
import { createBotCommand, type FollowUpInteraction } from "#/modules/commands";
createBotCommand({
data: new SlashCommandBuilder().setName("counter").setDescription("A simple counter"),
async execute(interaction) {
const button = new ButtonBuilder()
.setCustomId("counter:increment") // prefix must match command name
.setLabel("Click me")
.setStyle(ButtonStyle.Primary);
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(button);
await interaction.reply({ content: "Count: 0", components: [row] });
},
async followUp(interaction: FollowUpInteraction) {
if (interaction.isButton()) {
await interaction.reply("Button clicked!");
}
},
});- Run
pnpm unregister(orpnpm unregister --dev) to clean the slate - Delete the file from
./src/commands/ - Run
pnpm register(orpnpm register --dev) to register commands again - Restart your bot
The template includes several examples demonstrating different patterns:
| Command | Description |
|---|---|
ping |
Simple reply |
roll |
Slash command with options (dropdown selection) |
roll-plus |
String input parsing with image generation |
roll-panel |
Interactive buttons and select menus with followUp |
src/
├── commands/ # Drop command files here — auto-loaded
│ ├── ping.ts
│ ├── roll.ts
│ ├── roll-panel.ts
│ └── roll-plus.ts
├── modules/
│ ├── bot.ts # Client setup, login, graceful shutdown
│ ├── commands.ts # createBotCommand + auto-loader
│ ├── interaction.ts # Dispatches interactions to commands
│ └── logger.ts # Pino logger config
├── utils/ # Shared utilities (dice rolling, image gen)
└── main.ts # Entry pointManaged by envalid with full type safety:
| Variable | Description |
|---|---|
DISCORD_CLIENT_ID |
Your Discord application's client ID |
DISCORD_CLIENT_TOKEN |
Your Discord bot token |
LOG_LEVEL |
Logging level (trace, debug, info, warn, error, fatal) |
DEV_SERVER. |
Your Discord test server (target of register/unregister with --dev) |
See ./src/env.ts for the schema definition.
This repository uses GitHub Actions for CI. The workflow is defined in .github/workflows/checks.yml.
It automates:
- Linting & Formatting: Running Biome.
- Type Checking: Running TypeScript type checking.
- Testing: Running Vitest with code coverage (generated by Istanbul).
You might want to install the recommended extensions in vscode. Search for @recommended in the extensions tab, they'll show up as "workspace recommendations".
If you have been using eslint and prettier and their extensions, you might want to disable eslint entirely and keep prettier as the formatter only for certain types of files.
This is done by the .vscode/settings.json file.
Debug configuration is also included for running the source directly with node.
MIT