npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2024 – Pkg Stats / Ryan Hefner

discordjs-typed-commands

v0.2.1

Published

Type safe commands for discord.js

Downloads

12

Readme

discord.js typed commands

About

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.

Table of Contents

Installation

Install the package via npm:

npm install discordjs-typed-commands

Basic usage

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.

Details

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.

Narrowing the interaction type

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.

Accessing interaction options

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 command user option would be inferred as a User object.

  • The play command coin-toss subcommand coin 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 the typed function and export as isTyped. 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.

Commands vs subcommands

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.

Option types and values

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;
}

Use a specific command as function parameter:

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);
    }
});

Use a specific subcommand as function parameter:

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 ^

Defining command list as a type

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 */
}

Autocomplete commands

Version 0.2 adds support for autocomplete commands.

FAQ

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);

Todo

  • [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

Changelog

GitHub releases

License

MIT License