Implements a type system that provides type-safety, intellisense and autocompletions for command names, subcommands, option types and option choices for the discord.js library.
Install the package via npm:
npm install discordjs-typed-commands
In your project, create a file where you define your commands and import typed
and ReadonlyCommandList
from the library. Declare your commands
then pass that array to the typed
function and export it for usage elsewhere in your project.
/* commands/_commands.ts */
import { typed } from 'discordjs-typed-commands';
import type { ReadonlyCommandList } from 'discordjs-typed-commands';
export const commands = [
/* your command list goes here */
] as const satisfies ReadonlyCommandList;
export const isTyped = typed(commands);
Important: you must use
as const satisfies ReadonlyCommandList
when you declare your commands.
Import isTyped
anywhere you need it (usually where your Discord client is expected to receive interactions) and you're ready to go!
/* app.ts */
import { Client, Events } from 'discord.js';
import { isTyped } from './commands/_commands.js';
const discord = new Client({ intents: [ /* ... */] });
discord.on(Events.InteractionCreate, async interaction => {
if (isTyped.command(interaction, 'play')) {
if (isTyped.subcommand(interaction, 'coin-toss')) {
const coin = interaction.options.get('coin').value;
/* 'heads' | 'tails' */
}
}
else if (isTyped.command(interaction, 'greet')) {
const user = interaction.options.get('user').user;
/* User object */
}
});
Check out the example directory for a complete demo.
The examples demonstrated in this section will assume you have a command list (commands
) with the following structure:
commands
├─ greet
| └─ user (o)
├─ play
| └─ coinflip (s)
│ └─ coin (o)
| ├─ heads (c)
| └─ tails (c)
│ ├─ rock-paper-scissors (s)
| | └─ hand (o)
| | ├─ rock (c)
| | ├─ paper (c)
| | └─ scissors (c)
s = subcommand | o = option | c = choice
For full code implementation of the above, check out commands/_commands.ts in the example directory.
As seen in the example from the "Basic Usage" section, we invoke the typed
function and supply a list of our commands
as it's only paramater. The newly created isTyped
function is the one that holds all the type information for our commands.
const isTyped = typed(commands);
When the Discord client receives an interaction, we use the .command
method of this function to determine which one of our commands matches this interaction. It receives the interaction
object as it's first parameter, and the name of the command as it's second.
import { isTyped } from './commands/_commands_.js';
discord.on(Events.InteractionCreate, async interaction => {
if (isTyped.command(interaction, 'greet')) {
/* This is a "greet" interaction */
}
});
Note: Under the hood, the command method is just a type guard function, which builds on top of isChatInputCommand from discord.js.
Similarly, there is way to check for subcommands, but more on that later.
In order to access the interaction options, narrow down the interaction type just as demonstrated in the previous section, then you can start accessing them via the interaction.options.get
method.
discord.on(Events.InteractionCreate, async interaction => {
if (isTyped.command(interaction, 'greet')) {
/* User object */
const user = interaction.options.get('user').user;
/* or with destructuring */
const { user } = interaction.options.get('user');
}
});
We access all interaction options via the get
method only, since this is what give us type-safety, intellisense and autocomplete. There is no need to use getString
, getBoolean
, getUser
or other methods from discord.js.
For example:
-
The
greet
commanduser
option would be inferred as aUser
object. -
The
play
commandcoin-toss
subcommandcoin
option could be narrowed down to a string literal union of'heads' | 'tails'
.
/* greet command */
const user = interaction.options.get('user').user;
user.username; /* string */
user.tag; /* string */
/* coin-toss subcommand */
const coin = interaction.options.get('choice').value;
coin; /* 'heads' | 'tails' */
Note: All of this works because our
commands
list from earlier is defined as an immutable array, which we then pass to thetyped
function and export asisTyped
. This library puts all pieces of the puzzle together so TypeScript knows at compile time (when you're editing your code) what data to expect from each individual command.
You will notice that if you narrow down the interaction to play
and try to access it's options, Typescript you will give you an error:
discord.on(Events.InteractionCreate, async interaction => {
if (isTyped.command(interaction, 'play')) {
const coin = interaction.options.get('coin').value;
/* Error: Argument of type is not assignable to parameter of type never. */
}
});
This is because our first command greet
has no subcommands, so we are able to access it's options directly. But the play
command has two subcommands, coin-toss
and rock-paper-scissors
, and so far we haven't done any checks to determine which type of subcommand our interaction
holds.
Technically this piece of code probably won't crash your application, but it wouldn't make sense to try and access the coin
option if our interaction subcommand is rock-paper-scissors
. Likewise, it wouldn't make sense to access the hand
option if the subcommand is coin-toss
, in runtime it's always going to return null
in both cases.
The solution is really simple, if your command has subcommands, narrow down the subcommand first:
discord.on(Events.InteractionCreate, async interaction => {
if (isTyped.command(interaction, 'play')) {
/* can NOT use interaction.options.get('...') yet */
const coin = interaction.options.get('coin').value;
/* Error: ... ^^^^ */
if (isTyped.subcommand(interaction, 'coin-toss')) {
/* can now use interaction.options.get('...') */
const coin = interaction.options.get('coin').value;
}
else if (isTyped.subcommand(interaction, 'rock-paper-scissors')) {
/* can now use interaction.options.get('...') */
const hand = interaction.options.get('hand').value;
}
}
});
In summary:
- If the command has any subcommands, narrow down which subcommand the
interaction
has. - If the command has no subcommands, you can use
interaction.options.get('...')
directly.
Note: This is not something you have to actively think or worry about, since again, if you haven't narrowed down the subcommand, TypeScript will just give you an error or if there is no subcommand you wouldn't attempt narrowing.
Additionally, the Discord API does not allow subcommands and options of basic type as siblings, so that makes things quite a bit easier. When you define the list of your
commands
as shown earlier, you will also get errors at compile time if you input data of the wrong type or structure.
The way you declare your commands and their options determines what kind of types to expect, and the required
and choices
properties play a special role. This is best demonstrated with an example. Consider the following command:
/* commands/_commands.ts */
const commands = [
{
name: 'option-types',
options: [
{ name: 'A', type: ApplicationCommandOptionType.String, required: true,
choices: [
{ name: 'foo', value: 'foo-value' },
{ name: 'bar', value: 'bar-value' },
]
},
{ name: 'B', type: ApplicationCommandOptionType.String, required: true },
{ name: 'C', type: ApplicationCommandOptionType.String, required: false,
choices: [
{ name: 'foo', value: 'foo-value' },
{ name: 'bar', value: 'bar-value' },
]
},
{ name: 'D', type: ApplicationCommandOptionType.String, required: false },
]
}
] as const satisfies ReadonlyCommandList;
Note: required is
false
by default (if omitted).
option (name) | required? | choices? |
---|---|---|
A | ✓ | ✓ |
B | ✓ | ✖ |
C | ✖ | ✓ |
D | ✖ | ✖ |
A command defined as such allows us to determine what kind of value each option has at compile time:
if (isTyped.command(interaction, 'option-types')) {
/** 'foo-value' | 'bar-value' */
const a = interaction.options.get('A').value;
/** string */
const b = interaction.options.get('B').value;
/* 'foo-value' | 'bar-value' | undefined */
const c = interaction.options.get('C')?.value;
/* string | undefined */
const d = interaction.options.get('D')?.value;
}
You can define a specific command as a type, then use that type as a function parameter. This is useful if you want to pass down your interaction from one function to another, and/or restrict what type of interaction the function accepts.
/* some parts skipped for brevity */
import { TypedCommand } from 'discordjs-typed-commands';
const commands = [ /* ... */ ] as const satisfies ReadonlyCommandList;
type GreetCommand = TypedCommand<typeof commands, 'greet'>;
async function handleGreet(interaction: GreetCommand) {
/* This function will only accept the "greet" command */
}
discord.on(Events.InteractionCreate, async interaction => {
if (isTyped.command(interaction, 'greet')) {
await handleGreet(interaction);
}
});
Similarly, you can do this for subcommands.
/* some parts skipped for brevity */
import { TypedSubcommand } from 'discordjs-typed-commands';
const commands = [ /* ... */ ] as const satisfies ReadonlyCommandList;
type CoinTossSubcommand = TypedSubcommand<typeof commands, 'play', 'coin-toss'>;
async function handleCoinToss(interaction: CoinTossSubcommand) {
/* This function will only accept the "coin-toss" subcommands */
}
discord.on(Events.InteractionCreate, async interaction => {
if (isTyped.command(interaction, 'play')) {
/* narrow down the subcommand first */
if (isTyped.subcommand(interaction, 'coin-toss')) {
await handleCoinToss(interaction); /* success! */
}
}
});
Important: in order to get editor autocomplete when defining a subcommand type, supply the first parameter (
typeof commands
), then leave the last two as empty strings:
/* Define (write down) your type like this at first: */
type ExampleSubcommand1 = TypedSubcommand<typeof commands, '', ''>;
/* Then you will get autocomplete for the 2nd and then the last generic parameters */
type ExampleSubcommand1 = TypedSubcommand<typeof commands, 'play', ''>;
type ExampleSubcommand1 = TypedSubcommand<typeof commands, 'play', 'coin-toss'>;
/* If you start writing this, autocomplete won't work: */
type ExampleSubcommand1 = TypedSubcommand<typeof commands, ''
/* no autocomplete here ^
You can create a single type that holds all your commands
via the TypedCommandList
helper type. This lets you organize and structure your code easier.
/* commands/_commands.ts */
import { TypedCommandList } from 'discordjs-typed-commands';
const commands = [ /* ... */ ] as const satisfies ReadonlyCommandList;
export type Command = TypedCommandList<typeof commands>;
/* greet.ts */
import { Command } from './commands/_commands.ts';
async function handleGreet(interaction: Command['greet']) {
/* This function will only accept the "greet" command */
}
/* play.ts */
import { Command } from './commands/_commands.ts';
async function handlePlay(interaction: Command['play']) {
/* This function will only accept the "play" command */
}
Version 0.2 adds support for autocomplete commands.
Q: Does this package support CommonJS (require)
A: Sorry, no, and there are no plans to. Read more here.
Q: How can I contribute?
A: If you are typescript wizard (I am not) and you want to help improve this, you are more than welcome to do so, just submit an issue or a PR.
Q: Can I use the SlashCommandBuilder
that comes from discord.js?
A: Unless there is a way for TypeScript to infer what the return type of SlashCommandBuilder
you can't. But you could use it's toJson
method, which serializes the builder to API-compatible JSON data, which then you can copy and paste as your command list.
Alternatively, you can make this part of your build process like this:
import { SlashCommandBuilder } from 'discord.js';
import { writeFile } from 'node:fs/promises';
const commands = [
new SlashCommandBuilder().setName('echo').setDescription('Replies with your input!').toJSON(),
new SlashCommandBuilder().setName('ping').setDescription('Pings!').toJSON(),
];
const output = `
import { typed } from 'discordjs-typed-commands';
import type { ReadonlyCommandList, TypedCommandList } from 'discordjs-typed-commands';
export const commands = ${JSON.stringify(commands, null, 4)} as const satisfies ReadonlyCommandList;
export const isTyped = typed(commands);
export type Commands = TypedCommandList<typeof commands>;
`;
await writeFile('./path/to/commands.ts', output);
- [x] docs: Improve readme docs
- [x] feat: Add support for autocomplete interactions
- [ ] docs: Document autocomplete interactions
- [ ] docs: Provide internal docs
- [ ] test: Test support for yarn and pnpm
- [ ] test: Add husky hooks
- [ ] refactor: Confine public exports