DJS Commandsv2 docs
Concepts

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

FieldTypeDescription
namestringThe slash-command name. Must match Discord's naming rules: 1–32 characters, lowercase, [a-z0-9_-].
descriptionstringThe 1–100-character description shown in the Discord client.
run(ctx) => void | Promise<void>The handler. Receives a CommandRunContext.

Optional fields

FieldTypeNotes
optionsCommandOptionsTyped schema for slash-command options — see Options.
validatorsValidator[]Custom pre-handler checks. See Validators.
cooldownCooldownConfigRate limit. See Cooldowns.
ownerOnlybooleanWhen true, only IDs in botOwners (passed to the handler) can run it.
guildOnlybooleanWhen true, blocks DM invocations.
channelsstring[]Allow-list of channel IDs the command can run in.
permissionsPermissionsString[]Discord member permissions required (e.g. ["BanMembers"]).
rolesstring[]Role IDs the invoking member must have.
legacyCommandLegacyConfigOpt 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:

  1. Subscribes to interactionCreate. When a chat-input interaction comes in, it looks up the command by name and dispatches it through validators → cooldown gates → plugins → run.
  2. Registers the command list with Discord on clientReady. This calls client.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 teardown

destroy() 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 defineCommand result as the default export so the autoloader can pick it up.
  • Keep run thin. Move business logic into ordinary functions you can unit-test without spinning up a Discord client.
  • Prefer ctx.reply over ctx.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

On this page