Skip to main content

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

  1. Go to Discord Developer Portal
  2. Click "New Application" → Name it
  3. Go to "Bot" → Click "Add Bot"
  4. Copy the Bot Token
  5. Enable: Message Content Intent, Server Members Intent
  6. 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

PlatformSetupCost
RailwayConnect GitHub, deployFree tier available
Fly.ioDockerfile, fly deployFree tier (3 VMs)
VPS (DigitalOcean)PM2 + Node.js$4/month
Raspberry Pisystemd serviceHardware cost only

Common Mistakes

MistakeImpactFix
Not enabling intentsBot can't read messagesEnable intents in Developer Portal
Global command deploymentTakes up to 1 hour to updateUse guild commands for development
Not handling interaction timeouts"This interaction failed" after 3 secondsRespond or defer within 3 seconds
Blocking the event loopBot becomes unresponsiveUse async/await, avoid sync operations
Storing state in memoryLost on restartUse a database for persistent data

Building bots? Compare Discord API vs Slack API on APIScout — bot platforms, features, and developer experience.

Comments