Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Type safe custom context properties in TypeScript #1157

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 26 additions & 22 deletions src/App.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import {
import { IncomingEventType, getTypeAndConversation, assertNever } from './helpers';
import { CodedError, asCodedError, AppInitializationError, MultipleListenerError, ErrorCode } from './errors';
import { AllMiddlewareArgs } from './types/middleware';
import { StringIndexed } from './types/helpers';
// eslint-disable-next-line import/order
import allSettled = require('promise.allsettled'); // eslint-disable-line @typescript-eslint/no-require-imports
// eslint-disable-next-line @typescript-eslint/no-require-imports, import/no-commonjs
Expand Down Expand Up @@ -174,7 +175,7 @@ class WebClientPool {
/**
* A Slack App
*/
export default class App {
export default class App<CustomContext extends StringIndexed = StringIndexed> {
/** Slack Web API client */
public client: WebClient;

Expand Down Expand Up @@ -442,8 +443,8 @@ export default class App {
*
* @param m global middleware function
*/
public use(m: Middleware<AnyMiddlewareArgs>): this {
this.middleware.push(m);
public use(m: Middleware<AnyMiddlewareArgs, CustomContext>): this {
this.middleware.push(m as Middleware<AnyMiddlewareArgs>);
return this;
}

Expand Down Expand Up @@ -479,15 +480,15 @@ export default class App {

public event<EventType extends string = string>(
eventName: EventType,
...listeners: Middleware<SlackEventMiddlewareArgs<EventType>>[]
...listeners: Middleware<SlackEventMiddlewareArgs<EventType>, CustomContext>[]
): void;
public event<EventType extends RegExp = RegExp>(
eventName: EventType,
...listeners: Middleware<SlackEventMiddlewareArgs<string>>[]
...listeners: Middleware<SlackEventMiddlewareArgs<string>, CustomContext>[]
): void;
public event<EventType extends EventTypePattern = EventTypePattern>(
eventNameOrPattern: EventType,
...listeners: Middleware<SlackEventMiddlewareArgs<string>>[]
...listeners: Middleware<SlackEventMiddlewareArgs<string>, CustomContext>[]
): void {
let invalidEventName = false;
if (typeof eventNameOrPattern === 'string') {
Expand All @@ -513,9 +514,9 @@ export default class App {

// TODO: just make a type alias for Middleware<SlackEventMiddlewareArgs<'message'>>
// TODO: maybe remove the first two overloads
public message(...listeners: Middleware<SlackEventMiddlewareArgs<'message'>>[]): void;
public message(pattern: string | RegExp, ...listeners: Middleware<SlackEventMiddlewareArgs<'message'>>[]): void;
public message(...patternsOrMiddleware: (string | RegExp | Middleware<SlackEventMiddlewareArgs<'message'>>)[]): void {
public message(...listeners: Middleware<SlackEventMiddlewareArgs<'message'>, CustomContext>[]): void;
public message(pattern: string | RegExp, ...listeners: Middleware<SlackEventMiddlewareArgs<'message'>, CustomContext>[]): void;
public message(...patternsOrMiddleware: (string | RegExp | Middleware<SlackEventMiddlewareArgs<'message'>, CustomContext>)[]): void {
const messageMiddleware = patternsOrMiddleware.map((patternOrMiddleware) => {
if (typeof patternOrMiddleware === 'string' || util.types.isRegExp(patternOrMiddleware)) {
return matchMessage(patternOrMiddleware);
Expand All @@ -532,21 +533,21 @@ export default class App {

public shortcut<Shortcut extends SlackShortcut = SlackShortcut>(
callbackId: string | RegExp,
...listeners: Middleware<SlackShortcutMiddlewareArgs<Shortcut>>[]
...listeners: Middleware<SlackShortcutMiddlewareArgs<Shortcut>, CustomContext>[]
): void;
public shortcut<
Shortcut extends SlackShortcut = SlackShortcut,
Constraints extends ShortcutConstraints<Shortcut> = ShortcutConstraints<Shortcut>,
>(
constraints: Constraints,
...listeners: Middleware<SlackShortcutMiddlewareArgs<Extract<Shortcut, { type: Constraints['type'] }>>>[]
...listeners: Middleware<SlackShortcutMiddlewareArgs<Extract<Shortcut, { type: Constraints['type'] }>>, CustomContext>[]
): void;
public shortcut<
Shortcut extends SlackShortcut = SlackShortcut,
Constraints extends ShortcutConstraints<Shortcut> = ShortcutConstraints<Shortcut>,
>(
callbackIdOrConstraints: string | RegExp | Constraints,
...listeners: Middleware<SlackShortcutMiddlewareArgs<Extract<Shortcut, { type: Constraints['type'] }>>>[]
...listeners: Middleware<SlackShortcutMiddlewareArgs<Extract<Shortcut, { type: Constraints['type'] }>>, CustomContext>[]
): void {
const constraints: ShortcutConstraints = typeof callbackIdOrConstraints === 'string' || util.types.isRegExp(callbackIdOrConstraints) ?
{ callback_id: callbackIdOrConstraints } :
Expand All @@ -572,22 +573,22 @@ export default class App {
// https://basarat.gitbooks.io/typescript/docs/types/generics.html#design-pattern-convenience-generic
public action<Action extends SlackAction = SlackAction>(
actionId: string | RegExp,
...listeners: Middleware<SlackActionMiddlewareArgs<Action>>[]
...listeners: Middleware<SlackActionMiddlewareArgs<Action>, CustomContext>[]
): void;
public action<
Action extends SlackAction = SlackAction,
Constraints extends ActionConstraints<Action> = ActionConstraints<Action>,
>(
constraints: Constraints,
// NOTE: Extract<> is able to return the whole union when type: undefined. Why?
...listeners: Middleware<SlackActionMiddlewareArgs<Extract<Action, { type: Constraints['type'] }>>>[]
...listeners: Middleware<SlackActionMiddlewareArgs<Extract<Action, { type: Constraints['type'] }>>, CustomContext>[]
): void;
public action<
Action extends SlackAction = SlackAction,
Constraints extends ActionConstraints<Action> = ActionConstraints<Action>,
>(
actionIdOrConstraints: string | RegExp | Constraints,
...listeners: Middleware<SlackActionMiddlewareArgs<Extract<Action, { type: Constraints['type'] }>>>[]
...listeners: Middleware<SlackActionMiddlewareArgs<Extract<Action, { type: Constraints['type'] }>>, CustomContext>[]
): void {
// Normalize Constraints
const constraints: ActionConstraints = typeof actionIdOrConstraints === 'string' || util.types.isRegExp(actionIdOrConstraints) ?
Expand All @@ -608,23 +609,26 @@ export default class App {
this.listeners.push([onlyActions, matchConstraints(constraints), ...listeners] as Middleware<AnyMiddlewareArgs>[]);
}

public command(commandName: string | RegExp, ...listeners: Middleware<SlackCommandMiddlewareArgs>[]): void {
public command(
commandName: string | RegExp,
...listeners: Middleware<SlackCommandMiddlewareArgs, CustomContext>[]
): void {
this.listeners.push([onlyCommands, matchCommandName(commandName), ...listeners] as Middleware<AnyMiddlewareArgs>[]);
}

public options<Source extends OptionsSource = 'block_suggestion'>(
actionId: string | RegExp,
...listeners: Middleware<SlackOptionsMiddlewareArgs<Source>>[]
...listeners: Middleware<SlackOptionsMiddlewareArgs<Source>, CustomContext>[]
): void;
// TODO: reflect the type in constraits to Source
public options<Source extends OptionsSource = OptionsSource>(
constraints: ActionConstraints,
...listeners: Middleware<SlackOptionsMiddlewareArgs<Source>>[]
...listeners: Middleware<SlackOptionsMiddlewareArgs<Source>, CustomContext>[]
): void;
// TODO: reflect the type in constraits to Source
public options<Source extends OptionsSource = OptionsSource>(
actionIdOrConstraints: string | RegExp | ActionConstraints,
...listeners: Middleware<SlackOptionsMiddlewareArgs<Source>>[]
...listeners: Middleware<SlackOptionsMiddlewareArgs<Source>, CustomContext>[]
): void {
const constraints: ActionConstraints = typeof actionIdOrConstraints === 'string' || util.types.isRegExp(actionIdOrConstraints) ?
{ action_id: actionIdOrConstraints } :
Expand All @@ -635,15 +639,15 @@ export default class App {

public view<ViewActionType extends SlackViewAction = SlackViewAction>(
callbackId: string | RegExp,
...listeners: Middleware<SlackViewMiddlewareArgs<ViewActionType>>[]
...listeners: Middleware<SlackViewMiddlewareArgs<ViewActionType>, CustomContext>[]
): void;
public view<ViewActionType extends SlackViewAction = SlackViewAction>(
constraints: ViewConstraints,
...listeners: Middleware<SlackViewMiddlewareArgs<ViewActionType>>[]
...listeners: Middleware<SlackViewMiddlewareArgs<ViewActionType>, CustomContext>[]
): void;
public view<ViewActionType extends SlackViewAction = SlackViewAction>(
callbackIdOrConstraints: string | RegExp | ViewConstraints,
...listeners: Middleware<SlackViewMiddlewareArgs<ViewActionType>>[]
...listeners: Middleware<SlackViewMiddlewareArgs<ViewActionType>, CustomContext>[]
): void {
const constraints: ViewConstraints = typeof callbackIdOrConstraints === 'string' || util.types.isRegExp(callbackIdOrConstraints) ?
{ callback_id: callbackIdOrConstraints, type: 'view_submission' } :
Expand Down
8 changes: 4 additions & 4 deletions src/types/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,17 @@ export type AnyMiddlewareArgs =
| SlackViewMiddlewareArgs
| SlackShortcutMiddlewareArgs;

export interface AllMiddlewareArgs {
context: Context;
export interface AllMiddlewareArgs<CustomContext = StringIndexed> {
context: Context & CustomContext;
logger: Logger;
client: WebClient;
next: NextFn;
}

// NOTE: Args should extend AnyMiddlewareArgs, but because of contravariance for function types, including that as a
// constraint would mess up the interface of App#event(), App#message(), etc.
export interface Middleware<Args> {
(args: Args & AllMiddlewareArgs): Promise<void>;
export interface Middleware<Args, CustomContext = StringIndexed> {
(args: Args & AllMiddlewareArgs<CustomContext>): Promise<void>;
}

/**
Expand Down