Skip to content
discord.js 14 · typescript

The whole Discord bot, wired and typed.

Everything a Discord bot needs, commands, events, components, gates, lifecycle and plugins, wired and typed on top of discord.js. A wrong route or option is a compile error, before the bot ever connects.

  • gateway & http transports
  • typed slash commands
  • typed slash options
  • subcommand routing
  • autocomplete handlers
  • context menu commands
  • typed command mentions
  • typed emojis
  • typed customId codec
  • ComponentsV2 first
  • button & selectmenu handlers
  • modal handlers
  • confirmation prompts
  • restart-proof pagination
  • permission & role gates
  • guild / DM / NSFW gates
  • cooldown gate
  • sliding-window rate limiter
  • composable custom & effect gates
  • Notice / Fault / Silence error flow
  • webhook fault reporters
  • coordinated startup & shutdown
  • HTTP health check
  • logger with channels & sinks
  • typed pub/sub bus
  • interaction & event middleware
  • typed event handlers & waitFor
  • Vite HMR hot reload, gateway stays alive
  • Ink dev UI
  • cloudflared dev tunnel
  • project scaffolding
  • seedcord codegen
  • seedcord commands wizard
  • typed plugins
  • Postgres & Mongo plugins
  • eslint rules for discord.js

The builder is the source of the types.

You define options on the standard discord.js builder for slash commands or context menu commands. seedcord codegen reads it and writes the accessor types, so the builder stays the only schema you maintain.

  • Choices become a literal union

    'books' | 'films'

  • Required options are never null

    use it directly

  • Getter signatures regenerate on change

seedcord-gen.d.ts
// Generated by seedcord codegen. Do not edit by hand.
declare module '@seedcord/gateway' {
    interface SlashOptionRegistry {
        search: {
            category: {
                kind: 'string';
                required: true;
                choices: ['books', 'films'];
            };
        };
    }
}
commands/search.ts
import {
    RegisterCommand, BuilderComponent
} from '@seedcord/gateway';

@RegisterCommand('global')
export class SearchCommand extends
    BuilderComponent<'command'> {
    constructor() {
        super('command');

        this.instance
            .setName('search')
            .setDescription('Search the catalog')
            .addStringOption((o) =>
                o
                    .setName('category')
                    .setDescription('What to look through')
                    .setRequired(true)
                    .addChoices(
                        { name: 'Books', value: 'books' },
                        { name: 'Films', value: 'films' }
                    )
            );
    }
}
handlers/search.ts
import {
    SlashRoute, SlashHandler
} from '@seedcord/gateway';

@SlashRoute('search')
export class SearchHandler extends
    SlashHandler<'search'> {
    public async execute(): Promise<void> {
        // generated accessor, no cast,
        // no null check
        const category =
            this.options.getString('category');
        //    ^?  'books' | 'films'

        await this.reply(`Searching ${category}`);
    }
}

the resolved type

Hover category and the editor says 'books' | 'films'. You never typed that union.

proof.ts
const category = getString('category');
//    ^?  'books' | 'films'

const valid: 'books' | 'films' = category; // OK
const wrong: 'audio' = category; // Error 2322

Same command.
Far less to write.

by handseedcord · one handler
by hand
// build it, route to the subcommand,
// validate, all by hand
const data = new SlashCommandBuilder()
    .setName('library')
    .addSubcommand((s) =>
        s.setName('search').addStringOption(...)
    );

client.on(Events.InteractionCreate, async (i) => {
    if (!i.isChatInputCommand()) return;
    if (i.commandName !== 'library') return;
    if (i.options.getSubcommand() !== 'search') return;

    const raw = i.options.getString('query');
    if (raw === null) throw new Error('required');
    const query = raw as 'fiction' | 'nonfiction'; // cast

    // ...manual guard and cooldown checks...
    await i.reply(`Searching for ${query}`);
});

// plus a REST register script, plus a
// switch per subcommand,
// plus a full process restart on every edit
seedcord
import {
    SlashRoute, SlashHandler,
    Gated, GuildOnly
} from '@seedcord/gateway';

@Gated(GuildOnly())
@SlashRoute('library/search')
export class SearchHandler extends
    SlashHandler<'library/search'> {
    public async execute() {
        const query = this.options.getString('query');
        //    ^?  'fiction' | 'nonfiction'

        await this.reply(`Searching for ${query}`);
    }
}

// the route, registration and guards are done.
// edit, save, hot reload, the gateway stays up.

The left wires the routing, registration and checks by hand. seedcord does that wiring from your decorators.

100 characters.
Packed by a
typed schema.

Discord limits a customId to 100 characters. The codec packs your fields so more state fits than a joined string would, then decodes them back to exact, typed values.

encode(values) to stringdecode(id) to typed values
components/role-picker.ts
import {
    BuilderComponent, CustomId
} from '@seedcord/gateway';

export const Roles = new CustomId('roles')
    .snowflake('memberId')
    .oneOf('mode', ['add', 'remove']);

export class RolePicker extends
    BuilderComponent<'menu_role'> {
    constructor(memberId: string) {
        super('menu_role');
        const id = Roles.encode({
            memberId,
            mode: 'add'
        });
        this.instance
            .setPlaceholder('Roles to add')
            .setCustomId(id);
    }
}
handlers/role-picker.ts
import {
    SelectMenuHandler,
    SelectMenuKind,
    SelectMenuRoute
} from '@seedcord/gateway';
import { Roles } from '#components/role-picker';

@SelectMenuRoute(SelectMenuKind.Role, Roles)
export class RolePickerHandler extends SelectMenuHandler<
    SelectMenuKind.Role,
    [typeof Roles]
> {
    public async execute(): Promise<void> {
        const { memberId, mode } = this.params;
        // memberId: string, mode: 'add' | 'remove'
        const picked = this.event.values;
        await this.reply(
            `${mode} ${picked.length} <@${memberId}>`
        );
    }
}

Compose the guards.
The compiler checks.

Stack @Gated guards on a handler and combine them with and() and or(). Guild, owner, role, permission and cooldown each run at runtime, before your handler does. Attaching one to the wrong handler kind is a compile error.

and() every arm passesor() any one arm passes
handlers/ban.ts
import {
    Gated, and, or,
    GuildOnly, OwnerOnly, RequireRole,
    SlashRoute, SlashHandler
} from '@seedcord/gateway';

@Gated(or(
    OwnerOnly(),
    and(GuildOnly(), RequireRole(modRoleId))
))
@SlashRoute('ban')
export class BanHandler extends SlashHandler<'ban'> {
    public async execute() {
        // an owner, or a mod inside a guild, gets through
    }
}

The dev server has a UI.

seedcord dev starts your bot inside a terminal UI. Filter the log stream by channel and level, and watch the uptime beside it. On http you get the bound port and the tunnel status too. Save a handler and Vite swaps it in a few milliseconds with the bot still up, so your change reaches Discord without a restart.

The seedcord dev server running an http bot. A sidebar on the left shows the version, uptime, bound port and tunnel status. The log stream beside it shows a handler failing, reloading in 4ms, then serving the same command. A card at the bottom asks whether to refresh the changed commands.

It all comes built in.

Commands · 06

decorator routing

@SlashRoute, @ButtonRoute and four more bind handlers at startup

subcommand routing

subcommands and groups, routed by name

typed slash options

accessors generated from your command definitions

typed autocomplete

a handler per option, missing one is a compile error

command mentions

renders </route:id> once Discord assigns the id

emojis

resolved at startup, reached by name

Components & replies · 06

component handlers

buttons, selects and modals, routed by customId

customId codec

pack fields into 100 characters, decode them typed

context menus

user and message commands

multi-route handlers

one class serves several routes, narrowed by this.match

getConfirmation

an ephemeral confirm, resolves to a boolean

pagination

each nav button carries its page, so a restart keeps working

Events · 05

event handlers

body typed to the exact event

event emitter

event names and payloads typed together

typed waitFor

await a single typed event inline

pub/sub bus

framework events publish on default keys

middleware

runs before your handler, typed the same way

Guards & failures · 05

permission & role gates

checked before the handler runs

composable gates

stack them with and, or

cooldowns

scoped per user, guild or channel

rate limiter

a sliding window per key

errors

Notice to refuse, Fault to report, Silence to drop

Runtime · 04

lifecycle

phased startup and shutdown

logger

named channels, levels and sinks

health check

an HTTP endpoint that reports readiness

webhook reporters

faults posted to a Discord webhook

Tooling · 07

create seedcord

answers a few questions, writes the project

seedcord dev

a full-screen dev terminal

hot reload

Vite HMR swaps changed modules, the gateway stays connected

dev tunnel

opens cloudflared and points Discord at it

codegen

writes the types for your commands and config

seedcord commands

inspect and clean commands already deployed

eslint rules

flags payloads Discord rejects, before you send them

Plugins · 03

typed plugins

attach once, codegen types it on core

Mongoose

MongoDB, services typed by key

Kysely

Postgres, queries typed off your schema

Two transports.
One set of handlers.

Your handlers compile on both, and the import line will usually be the only difference.

@seedcord/gateway

Holds a websocket connection, built on discord.js.

Discord streams every event down it. Messages, joins, voice state, typing, and all other events. Pick it when your bot reacts to anything past interactions.

@seedcord/http

Answers Discord's interactions endpoint.

Discord POSTs each interaction to your URL, and seedcord verifies the Ed25519 signature before anything dispatches. Nothing else arrives here, so a commands-only bot never opens a connection.

From zero
to hot reload.

Scaffold a typed bot, open it, and run it. Routing, registration and the option types are wired for you, and hot reload keeps the gateway alive.

terminal
$ pnpm create seedcord my-bot # scaffold a typed bot
$ cd my-bot
$ seedcord dev # tui | hot reload, gateway alive

# bot online, every slash option fully typed