How to Build a Discord Bot with TypeScript
·APIScout Team
discorddiscord bottypescripttutorialapi integration
How to Build a Discord Bot with TypeScript
Discord bots power communities, automate moderation, and create interactive experiences. This guide uses discord.js v14 with TypeScript to build a bot with slash commands, interactive buttons, and rich embeds.
What You'll Build
- Slash commands (
/ping,/info,/poll) - Rich embeds with formatting
- Interactive buttons and select menus
- Message handling and moderation
- Command deployment to Discord
Prerequisites: Node.js 18+, TypeScript, Discord account.
1. Setup
Create Discord Application
- Go to Discord Developer Portal
- Click "New Application" → Name it
- Go to "Bot" → Click "Add Bot"
- Copy the Bot Token
- Enable: Message Content Intent, Server Members Intent
- Go to OAuth2 → URL Generator → Select: bot, applications.commands → Select permissions → Copy invite URL → Open in browser to add to your server
Install Dependencies
npm init -y
npm install discord.js
npm install -D typescript @types/node tsx
npx tsc --init
Project Structure
src/
index.ts # Bot startup
commands/ # Slash command handlers
ping.ts
info.ts
poll.ts
events/ # Event handlers
ready.ts
interactionCreate.ts
deploy-commands.ts # Register commands with Discord
Initialize Bot
// src/index.ts
import { Client, GatewayIntentBits, Collection } from 'discord.js';
import { readdir } from 'fs/promises';
import { join } from 'path';
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
],
});
// Load commands
client.commands = new Collection();
async function loadCommands() {
const commandFiles = await readdir(join(__dirname, 'commands'));
for (const file of commandFiles) {
if (!file.endsWith('.ts') && !file.endsWith('.js')) continue;
const command = await import(join(__dirname, 'commands', file));
client.commands.set(command.data.name, command);
}
}
// Handle interactions
client.on('interactionCreate', async (interaction) => {
if (!interaction.isChatInputCommand()) return;
const command = client.commands.get(interaction.commandName);
if (!command) return;
try {
await command.execute(interaction);
} catch (error) {
console.error(error);
const reply = { content: 'There was an error executing this command!', ephemeral: true };
if (interaction.replied) {
await interaction.followUp(reply);
} else {
await interaction.reply(reply);
}
}
});
client.once('ready', (c) => {
console.log(`✅ Logged in as ${c.user.tag}`);
});
loadCommands().then(() => {
client.login(process.env.DISCORD_TOKEN);
});
2. Slash Commands
Ping Command
// src/commands/ping.ts
import { SlashCommandBuilder, ChatInputCommandInteraction } from 'discord.js';
export const data = new SlashCommandBuilder()
.setName('ping')
.setDescription('Check bot latency');
export async function execute(interaction: ChatInputCommandInteraction) {
const sent = await interaction.reply({
content: 'Pinging...',
fetchReply: true,
});
const latency = sent.createdTimestamp - interaction.createdTimestamp;
const apiLatency = Math.round(interaction.client.ws.ping);
await interaction.editReply(
`🏓 Pong!\nLatency: ${latency}ms\nAPI Latency: ${apiLatency}ms`
);
}
Info Command with Embeds
// src/commands/info.ts
import { SlashCommandBuilder, EmbedBuilder, ChatInputCommandInteraction } from 'discord.js';
export const data = new SlashCommandBuilder()
.setName('info')
.setDescription('Get server or user info')
.addSubcommand(sub =>
sub.setName('server').setDescription('Get server info'))
.addSubcommand(sub =>
sub.setName('user')
.setDescription('Get user info')
.addUserOption(opt =>
opt.setName('target').setDescription('The user').setRequired(false)));
export async function execute(interaction: ChatInputCommandInteraction) {
const subcommand = interaction.options.getSubcommand();
if (subcommand === 'server') {
const guild = interaction.guild!;
const embed = new EmbedBuilder()
.setTitle(guild.name)
.setThumbnail(guild.iconURL())
.addFields(
{ name: 'Members', value: `${guild.memberCount}`, inline: true },
{ name: 'Created', value: `<t:${Math.floor(guild.createdTimestamp / 1000)}:R>`, inline: true },
{ name: 'Channels', value: `${guild.channels.cache.size}`, inline: true },
)
.setColor(0x5865F2)
.setTimestamp();
await interaction.reply({ embeds: [embed] });
}
if (subcommand === 'user') {
const user = interaction.options.getUser('target') || interaction.user;
const embed = new EmbedBuilder()
.setTitle(user.displayName)
.setThumbnail(user.displayAvatarURL({ size: 256 }))
.addFields(
{ name: 'ID', value: user.id, inline: true },
{ name: 'Joined', value: `<t:${Math.floor(user.createdTimestamp / 1000)}:R>`, inline: true },
)
.setColor(0x5865F2);
await interaction.reply({ embeds: [embed] });
}
}
Poll Command with Buttons
// src/commands/poll.ts
import {
SlashCommandBuilder, EmbedBuilder, ActionRowBuilder,
ButtonBuilder, ButtonStyle, ChatInputCommandInteraction,
} from 'discord.js';
export const data = new SlashCommandBuilder()
.setName('poll')
.setDescription('Create a poll')
.addStringOption(opt =>
opt.setName('question').setDescription('Poll question').setRequired(true))
.addStringOption(opt =>
opt.setName('option1').setDescription('First option').setRequired(true))
.addStringOption(opt =>
opt.setName('option2').setDescription('Second option').setRequired(true));
const pollVotes = new Map<string, Map<string, Set<string>>>();
export async function execute(interaction: ChatInputCommandInteraction) {
const question = interaction.options.getString('question')!;
const option1 = interaction.options.getString('option1')!;
const option2 = interaction.options.getString('option2')!;
const pollId = `poll_${Date.now()}`;
pollVotes.set(pollId, new Map([
['option1', new Set()],
['option2', new Set()],
]));
const embed = new EmbedBuilder()
.setTitle(`📊 ${question}`)
.addFields(
{ name: option1, value: 'Votes: 0', inline: true },
{ name: option2, value: 'Votes: 0', inline: true },
)
.setColor(0x5865F2)
.setFooter({ text: `Poll ID: ${pollId}` });
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId(`${pollId}_option1`)
.setLabel(option1)
.setStyle(ButtonStyle.Primary),
new ButtonBuilder()
.setCustomId(`${pollId}_option2`)
.setLabel(option2)
.setStyle(ButtonStyle.Secondary),
);
await interaction.reply({ embeds: [embed], components: [row] });
}
3. Deploy Commands
// src/deploy-commands.ts
import { REST, Routes } from 'discord.js';
import { readdir } from 'fs/promises';
import { join } from 'path';
const commands = [];
const commandFiles = await readdir(join(__dirname, 'commands'));
for (const file of commandFiles) {
if (!file.endsWith('.ts') && !file.endsWith('.js')) continue;
const command = await import(join(__dirname, 'commands', file));
commands.push(command.data.toJSON());
}
const rest = new REST().setToken(process.env.DISCORD_TOKEN!);
// Deploy to a specific guild (instant, for development)
await rest.put(
Routes.applicationGuildCommands(
process.env.DISCORD_CLIENT_ID!,
process.env.DISCORD_GUILD_ID!
),
{ body: commands }
);
console.log(`Deployed ${commands.length} commands`);
Run: npx tsx src/deploy-commands.ts
4. Button Interactions
// In your interactionCreate handler
client.on('interactionCreate', async (interaction) => {
if (interaction.isButton()) {
const [pollId, option] = interaction.customId.split('_');
// Handle poll vote
if (pollId.startsWith('poll')) {
const votes = pollVotes.get(`${pollId}_${option.split('_')[0]}`);
// Toggle vote, update embed...
await interaction.reply({
content: `You voted for option ${option}!`,
ephemeral: true,
});
}
}
});
5. Message Moderation
// Auto-moderate messages
client.on('messageCreate', async (message) => {
if (message.author.bot) return;
// Link filter
const linkRegex = /https?:\/\/[^\s]+/gi;
if (linkRegex.test(message.content) && !message.member?.permissions.has('ManageMessages')) {
await message.delete();
await message.channel.send({
content: `${message.author}, links are not allowed in this channel.`,
});
return;
}
// Spam detection (simple)
const recentMessages = await message.channel.messages.fetch({ limit: 5 });
const userMessages = recentMessages.filter(
m => m.author.id === message.author.id &&
Date.now() - m.createdTimestamp < 5000
);
if (userMessages.size >= 5) {
await message.member?.timeout(60_000, 'Spam detected');
await message.channel.send(`${message.author} has been timed out for spam.`);
}
});
6. Run the Bot
# Development
npx tsx --watch src/index.ts
# Production
npx tsc && node dist/index.js
Production Deployment
| Platform | Setup | Cost |
|---|---|---|
| Railway | Connect GitHub, deploy | Free tier available |
| Fly.io | Dockerfile, fly deploy | Free tier (3 VMs) |
| VPS (DigitalOcean) | PM2 + Node.js | $4/month |
| Raspberry Pi | systemd service | Hardware cost only |
Common Mistakes
| Mistake | Impact | Fix |
|---|---|---|
| Not enabling intents | Bot can't read messages | Enable intents in Developer Portal |
| Global command deployment | Takes up to 1 hour to update | Use guild commands for development |
| Not handling interaction timeouts | "This interaction failed" after 3 seconds | Respond or defer within 3 seconds |
| Blocking the event loop | Bot becomes unresponsive | Use async/await, avoid sync operations |
| Storing state in memory | Lost on restart | Use a database for persistent data |
Building bots? Compare Discord API vs Slack API on APIScout — bot platforms, features, and developer experience.