Skip to content

Commit

Permalink
(WIP) Make Handler more type-safe
Browse files Browse the repository at this point in the history
  • Loading branch information
Mimickal committed Aug 30, 2023
1 parent cff956d commit 3314291
Show file tree
Hide file tree
Showing 4 changed files with 35 additions and 12 deletions.
19 changes: 11 additions & 8 deletions src/builders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ const { name: PACKAGE_NAME } = require('../package.json');
/** Either a Builder or a function that returns a Builder. */
export type BuilderInput<T> = T | ((thing: T) => T);
/** The function called during command execution. */
export type Handler = (interaction: Discord.CommandInteraction) => unknown;
export type Handler<T extends Discord.BaseInteraction> = (interaction: T) => unknown;
/** A string option with the length set internally. */
export type SlashCommandCustomOption = Omit<Discord.SlashCommandStringOption,
'setMinLength' | 'setMaxLength'
Expand Down Expand Up @@ -82,12 +82,12 @@ class MoreOptionsMixin extends Discord.SharedSlashCommandOptions {
}

/** Mixin that adds the ability to set and store a command handler function. */
class CommandHandlerMixin {
class CommandHandlerMixin<T extends Discord.BaseInteraction> {
/** The function called when this command is executed. */
public readonly handler: Handler | undefined;
public readonly handler: Handler<T> | undefined;

/** Sets the function called when this command is executed. */
setHandler(handler: Handler): this {
setHandler(handler: Handler<T>): this {
if (typeof handler !== 'function') {
throw new Error(`handler was '${typeof handler}', expected 'function'`);
}
Expand Down Expand Up @@ -116,12 +116,15 @@ type SlashCommandSubcommandsOnlyBuilder = Omit<SlashCommandBuilder,
// Otherwise, we run the risk of stepping on field initialization.

export class ContextMenuCommandBuilder extends Mixin(
CommandHandlerMixin,
// This double mixin is dumb, but it's the only way to accept both
// ContextMenuCommandInteraction types.
CommandHandlerMixin<Discord.UserContextMenuCommandInteraction>,
CommandHandlerMixin<Discord.MessageContextMenuCommandInteraction>,
Discord.ContextMenuCommandBuilder,
) {}

export class SlashCommandBuilder extends Mixin(
CommandHandlerMixin,
CommandHandlerMixin<Discord.ChatInputCommandInteraction>,
MoreOptionsMixin,
Discord.SlashCommandBuilder,
) {
Expand Down Expand Up @@ -149,7 +152,7 @@ export class SlashCommandBuilder extends Mixin(
}

export class SlashCommandSubcommandGroupBuilder extends Mixin(
CommandHandlerMixin,
CommandHandlerMixin<Discord.ChatInputCommandInteraction>,
Discord.SlashCommandSubcommandGroupBuilder,
) {
// @ts-ignore We want to force this to only accept our version of the
Expand All @@ -164,7 +167,7 @@ export class SlashCommandSubcommandGroupBuilder extends Mixin(

export class SlashCommandSubcommandBuilder extends Mixin(
MoreOptionsMixin,
CommandHandlerMixin,
CommandHandlerMixin<Discord.ChatInputCommandInteraction>,
Discord.SlashCommandSubcommandBuilder,
) {}

Expand Down
11 changes: 8 additions & 3 deletions src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import {
BaseInteraction,
ChatInputCommandInteraction,
CommandInteraction,
ContextMenuCommandInteraction,
DiscordAPIError,
ContextMenuCommandBuilder as DiscordContextMenuCommandBuilder,
Expand All @@ -19,7 +20,6 @@ import {
Snowflake,
RESTPostAPIContextMenuApplicationCommandsJSONBody,
RESTPostAPIChatInputApplicationCommandsJSONBody,
CommandInteraction,
} from 'discord.js';

import {
Expand Down Expand Up @@ -77,7 +77,7 @@ export default class SlashCommandRegistry {
application_id: Snowflake | null = null;

/** The handler run for unrecognized commands. */
default_handler: Handler | null = null;
default_handler: Handler<BaseInteraction> | null = null;

/** A Discord guild ID used to restrict command registration to one guild. */
guild_id: Snowflake | null = null;
Expand Down Expand Up @@ -154,7 +154,7 @@ export default class SlashCommandRegistry {
* @throws If handler is not a function.
* @return Instance so we can chain calls.
*/
setDefaultHandler(handler: Handler): this {
setDefaultHandler(handler: Handler<BaseInteraction>): this {
if (typeof handler !== 'function') {
throw new Error(`handler was '${typeof handler}', expected 'function'`);
}
Expand Down Expand Up @@ -297,6 +297,11 @@ export default class SlashCommandRegistry {
builder_top.handler ??
this.default_handler;

// @ts-expect-error Discord.js Interaction types are mutually exclusive,
// despite all extending BaseInteraction. We do our best to make sure
// each individual handler is the right type, but the union of all of
// them here resolves to "never".
// https://discord.com/channels/222078108977594368/824411059443204127/1145960025962066033
return handler ? handler(interaction) as T : undefined;
}

Expand Down
16 changes: 15 additions & 1 deletion test/builders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,30 @@ import {
SlashCommandBuilder,
SlashCommandSubcommandBuilder,
SlashCommandSubcommandGroupBuilder,
SlashCommandRegistry,
} from '../src';
import { BuilderInput } from '../src/builders';

type CanSetHandler = new () => {
setHandler: (handler: Handler) => unknown;
setHandler: (handler: Handler<Discord.BaseInteraction>) => unknown;
}
type CanAddSubCommand = new () => {
addSubcommand: (input: BuilderInput<SlashCommandSubcommandBuilder>) => unknown;
};

// These are static type tests to ensure Handler can accept all of these types.
new SlashCommandRegistry()
.addContextMenuCommand(cmd => cmd
.setType(Discord.ApplicationCommandType.User)
.setHandler((int: Discord.UserContextMenuCommandInteraction) => {})
.setHandler((int: Discord.MessageContextMenuCommandInteraction) => {})
)
.addCommand(cmd => cmd
.setHandler((int: Discord.CommandInteraction) => {})
.setHandler((int: Discord.ChatInputCommandInteraction) => {})
)
.setDefaultHandler((int: Discord.BaseInteraction) => {})

describe('Builders have setHandler() functions injected', function() {
Array.of<CanSetHandler>(
ContextMenuCommandBuilder,
Expand Down
1 change: 1 addition & 0 deletions test/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export class MockCommandInteraction extends ChatInputCommandInteraction {
username: 'fake_test_user',
discriminator: '1234',
avatar: null,
global_name: null,

Check failure on line 74 in test/mock.ts

View workflow job for this annotation

GitHub Actions / build-and-test (16.x)

Type '{ id: string; username: string; discriminator: string; avatar: null; global_name: null; }' is not assignable to type 'APIUser'.

Check failure on line 74 in test/mock.ts

View workflow job for this annotation

GitHub Actions / build-and-test (18.x)

Type '{ id: string; username: string; discriminator: string; avatar: null; global_name: null; }' is not assignable to type 'APIUser'.
},
});

Expand Down

0 comments on commit 3314291

Please sign in to comment.