Commands
Define, register, and dispatch slash and prefix commands with @djs-commands/core.
A command in DJS Commands is a plain object. There are no decorators, no class hierarchies, and no on-disk conventions you have to follow. You build the object with defineCommand, you put the object somewhere your bot can find it, and you pass it to createCommandHandler.
defineCommand
defineCommand(command) returns its argument unchanged. It exists purely so the TypeScript compiler can give you autocomplete and structural validation as you type. At runtime it's identity.
import { defineCommand } from "@djs-commands/core";
const ping = defineCommand({
name: "ping",
description: "Replies with pong",
run: async ({ reply }) => {
await reply("pong");
},
});Required fields
| Field | Type | Description |
|---|---|---|
name | string | The slash-command name. Must match Discord's naming rules: 1–32 characters, lowercase, [a-z0-9_-]. |
description | string | The 1–100-character description shown in the Discord client. |
run | (ctx) => void | Promise<void> | The handler. Receives a CommandRunContext. |
Optional fields
| Field | Type | Notes |
|---|---|---|
options | CommandOptions | Typed schema for slash-command options — see Options. |
validators | Validator[] | Custom pre-handler checks. See Validators. |
cooldown | CooldownConfig | Rate limit. See Cooldowns. |
ownerOnly | boolean | When true, only IDs in botOwners (passed to the handler) can run it. |
guildOnly | boolean | When true, blocks DM invocations. |
channels | string[] | Allow-list of channel IDs the command can run in. |
permissions | PermissionsString[] | Discord member permissions required (e.g. ["BanMembers"]). |
roles | string[] | Role IDs the invoking member must have. |
legacy | CommandLegacyConfig | Opt this command into legacy prefix invocation; supports aliases. |
CommandRunContext
CommandRunContext is a discriminated union over the invocation source. Both branches share a unified surface so most handlers don't need to care whether they were invoked via slash or prefix:
type CommandRunContext = SlashRunContext | LegacyRunContext;
interface BaseRunContext {
client: Client;
author: User;
guild: Guild | null;
member: GuildMember | null;
channel: TextBasedChannel | null;
channelId: string | null;
options: ResolveOptions<S>; // typed from your `options` schema
reply: (content: string | { content?: string; ephemeral?: boolean }) => Promise<unknown>;
}
type SlashRunContext = BaseRunContext & { type: "slash"; interaction: ChatInputCommandInteraction };
type LegacyRunContext = BaseRunContext & { type: "legacy"; message: Message };When you need source-specific behavior, narrow on ctx.type:
run: async (ctx) => {
if (ctx.type === "slash") {
await ctx.interaction.deferReply({ ephemeral: true });
}
await ctx.reply("hi");
}Options
Define typed slash-command options with the options field. The handler context's options is fully inferred from your schema — required options resolve to their type, optional ones to T | undefined.
defineCommand({
name: "ban",
description: "Ban a user",
options: {
target: { type: "user", description: "Who to ban", required: true },
reason: { type: "string", description: "Why", required: false },
days: {
type: "integer",
description: "Days of messages to delete",
choices: [0, 1, 7] as const,
},
},
run: async ({ options, reply }) => {
// options.target: User
// options.reason: string | undefined
// options.days: 0 | 1 | 7 | undefined
await reply(`Banning ${options.target.tag}`);
},
});Supported option types: string, integer, number, boolean, user, channel, role, mentionable, attachment. string and integer accept choices for enum-style narrowing.
Registering commands
createCommandHandler accepts a client and an array of commands. It does two things on your behalf:
- Subscribes to
interactionCreate. When a chat-input interaction comes in, it looks up the command bynameand dispatches it through validators → cooldown gates → plugins →run. - Registers the command list with Discord on
clientReady. This callsclient.application.commands.set(...)with the names, descriptions, and options of every command you passed in.
import { createCommandHandler } from "@djs-commands/core";
import { Client, GatewayIntentBits } from "discord.js";
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
const handler = createCommandHandler({
client,
commands: [ping /*, more commands */],
});
await handler.ready;
await client.login(process.env.DISCORD_TOKEN);The returned handler exposes:
handler.ready; // Promise<void> — resolves once all plugin setup hooks complete
await handler.destroy(); // Removes listeners, awaits plugin teardowndestroy() does not delete the registered application commands from Discord — those persist until you explicitly clear them.
Loading commands from a directory
Pass a commandDir to the handler and every file's default export is registered automatically. In dev (NODE_ENV !== "production") the directory is watched and edited files hot-reload without a restart.
createCommandHandler({
client,
commandDir: "./src/commands",
});If you'd rather wire it up by hand, the loader functions are public:
import { loadCommandsFromDir, watchCommandsDir } from "@djs-commands/core";
const commands = await loadCommandsFromDir("./src/commands");
const watcher = watchCommandsDir("./src/commands", {
onCommandChange: () => { /* re-register */ },
});Best practices
- One command per file. Export the
defineCommandresult as the default export so the autoloader can pick it up. - Keep
runthin. Move business logic into ordinary functions you can unit-test without spinning up a Discord client. - Prefer
ctx.replyoverctx.interaction.reply/ctx.message.reply— it works in both slash and legacy contexts. - Don't share state via module scope. When a feature wants memory across calls (cooldowns, per-guild config), reach for the Storage adapter.
Last updated on
