Repository: slackapi/bolt-js (ts)

Issue:
Unable to build options request objects in TypeScript
The `OptionsRequest` interface does not allow developers to build an object compatible with the interface in TypeScript. We may have similar issues also with other interface.

---
@trevor-gullstad I got the point. Thanks a lot for being patient here. The situation arises on my end. 

We should resolve this TypeScript issue but I'm still exploring a better fix for this. The new interfaces like `BlockOptionsRequest` you added are not used inside the framework.

_Originally posted by @seratch in https://github.com/slackapi/bolt-js/issues/893#issuecomment-825322699_

Pull request:
Fix #894 Unable to build options request objects in TypeScript
###  Summary

This pull request resolves #894 by correcting the types for external data source request payloads. @trevor-gullstad flagged this issue by sending pull request #893 (thank you very much!). Refer to the issue and my comments in this pull request for more details.

### Requirements (place an `x` in each `[ ]`)

* [x] I've read and understood the [Contributing Guidelines](https://github.com/slackapi/bolt/blob/main/.github/contributing.md) and have done my best effort to follow them.
* [x] I've read and agree to the [Code of Conduct](https://slackhq.github.io/code-of-conduct).

Patch:
diff --git a/src/middleware/builtin.ts b/src/middleware/builtin.ts
index 50f2b59..77fff02 100644
--- a/src/middleware/builtin.ts
+++ b/src/middleware/builtin.ts
@@ -15,7 +15,10 @@ import {
   SlashCommand,
   ViewSubmitAction,
   ViewClosedAction,
-  OptionsRequest,
+  SlackOptions,
+  BlockSuggestion,
+  InteractiveMessageSuggestion,
+  DialogSuggestion,
   InteractiveMessage,
   DialogSubmitAction,
   GlobalShortcut,
@@ -76,7 +79,7 @@ export const onlyCommands: Middleware<AnyMiddlewareArgs & { command?: SlashComma
 /**
  * Middleware that filters out any event that isn't an options
  */
-export const onlyOptions: Middleware<AnyMiddlewareArgs & { options?: OptionsRequest }> = async ({ options, next }) => {
+export const onlyOptions: Middleware<AnyMiddlewareArgs & { options?: SlackOptions }> = async ({ options, next }) => {
   // Filter out any non-options requests
   if (options === undefined) {
     return;
@@ -385,8 +388,8 @@ function isBlockPayload(
     | SlackActionMiddlewareArgs['payload']
     | SlackOptionsMiddlewareArgs['payload']
     | SlackViewMiddlewareArgs['payload'],
-): payload is BlockElementAction | OptionsRequest<'block_suggestion'> {
-  return (payload as BlockElementAction | OptionsRequest<'block_suggestion'>).action_id !== undefined;
+): payload is BlockElementAction | BlockSuggestion {
+  return (payload as BlockElementAction | BlockSuggestion).action_id !== undefined;
 }
 
 type CallbackIdentifiedBody =
@@ -394,7 +397,8 @@ type CallbackIdentifiedBody =
   | DialogSubmitAction
   | MessageShortcut
   | GlobalShortcut
-  | OptionsRequest<'interactive_message' | 'dialog_suggestion'>;
+  | InteractiveMessageSuggestion
+  | DialogSuggestion;
 
 function isCallbackIdentifiedBody(
   body: SlackActionMiddlewareArgs['body'] | SlackOptionsMiddlewareArgs['body'] | SlackShortcutMiddlewareArgs['body'],
diff --git a/src/types/options/index.spec.ts b/src/types/options/index.spec.ts
new file mode 100644
index 0000000..3a863da
--- /dev/null
+++ b/src/types/options/index.spec.ts
@@ -0,0 +1,130 @@
+// tslint:disable:no-implicit-dependencies
+import { assert } from 'chai';
+import { BlockSuggestion, DialogSuggestion, InteractiveMessageSuggestion } from './index';
+
+describe('options types', () => {
+  it('should be compatible with block_suggestion payloads', () => {
+    const payload: BlockSuggestion = {
+      type: 'block_suggestion',
+      user: {
+        id: 'W111',
+        name: 'primary-owner',
+        team_id: 'T111',
+      },
+      container: { type: 'view', view_id: 'V111' },
+      api_app_id: 'A111',
+      token: 'verification_token',
+      block_id: 'block-id-value',
+      action_id: 'action-id-value',
+      value: 'search word',
+      team: {
+        id: 'T111',
+        domain: 'workspace-domain',
+        enterprise_id: 'E111',
+        enterprise_name: 'Sandbox Org',
+      },
+      view: {
+        id: 'V111',
+        team_id: 'T111',
+        type: 'modal',
+        blocks: [
+          {
+            type: 'input',
+            block_id: '5ar+',
+            label: { type: 'plain_text', text: 'Label' },
+            optional: false,
+            element: { type: 'plain_text_input', action_id: 'i5IpR' },
+          },
+          {
+            type: 'input',
+            block_id: 'block-id-value',
+            label: { type: 'plain_text', text: 'Search' },
+            optional: false,
+            element: {
+              type: 'external_select',
+              action_id: 'action-id-value',
+              placeholder: { type: 'plain_text', text: 'Select an item' },
+            },
+          },
+          {
+            type: 'input',
+            block_id: 'xxx',
+            label: { type: 'plain_text', text: 'Search (multi)' },
+            optional: false,
+            element: {
+              type: 'multi_external_select',
+              action_id: 'yyy',
+              placeholder: { type: 'plain_text', text: 'Select an item' },
+            },
+          },
+        ],
+        private_metadata: '',
+        callback_id: 'view-id',
+        state: { values: {} },
+        hash: '111.xxx',
+        title: { type: 'plain_text', text: 'My App' },
+        clear_on_close: false,
+        notify_on_close: false,
+        close: { type: 'plain_text', text: 'Cancel' },
+        submit: { type: 'plain_text', text: 'Submit' },
+        root_view_id: 'V111',
+        previous_view_id: null,
+        app_id: 'A111',
+        external_id: '',
+        app_installed_team_id: 'T111',
+        bot_id: 'B111',
+      },
+    };
+    assert.equal(payload.action_id, 'action-id-value');
+    assert.equal(payload.value, 'search word');
+  });
+
+  it('should be compatible with interactive_message payloads', () => {
+    const payload: InteractiveMessageSuggestion = {
+      name: 'bugs_list',
+      value: 'bot',
+      callback_id: 'select_remote_1234',
+      type: 'interactive_message',
+      team: {
+        id: 'T012AB0A1',
+        domain: 'pocket-calculator',
+      },
+      channel: {
+        id: 'C012AB3CD',
+        name: 'general',
+      },
+      user: {
+        id: 'U012A1BCJ',
+        name: 'bugcatcher',
+      },
+      action_ts: '1481670445.010908',
+      message_ts: '1481670439.000007',
+      attachment_id: '1',
+      token: 'verification_token_string',
+    };
+    assert.equal(payload.callback_id, 'select_remote_1234');
+    assert.equal(payload.value, 'bot');
+  });
+
+  it('should be compatible with dialog_suggestion payloads', () => {
+    const payload: DialogSuggestion = {
+      type: 'dialog_suggestion',
+      token: 'verification_token',
+      action_ts: '1596603332.676855',
+      team: {
+        id: 'T111',
+        domain: 'workspace-domain',
+        enterprise_id: 'E111',
+        enterprise_name: 'Sandbox Org',
+      },
+      user: { id: 'W111', name: 'primary-owner', team_id: 'T111' },
+      channel: { id: 'C111', name: 'test-channel' },
+      name: 'types',
+      value: 'search keyword',
+      callback_id: 'dialog-callback-id',
+      state: 'Limo',
+    };
+    assert.equal(payload.callback_id, 'dialog-callback-id');
+    assert.equal(payload.value, 'search keyword');
+  });
+});
diff --git a/src/types/options/index.ts b/src/types/options/index.ts
index 44ddc7f..c20cf07 100644
--- a/src/types/options/index.ts
+++ b/src/types/options/index.ts
@@ -1,31 +1,51 @@
 import { Option } from '@slack/types';
 import { StringIndexed, XOR } from '../helpers';
 import { AckFn } from '../utilities';
+import { ViewOutput } from '../view/index';
 
 /**
  * Arguments which listeners and middleware receive to process an options request from Slack
  */
 export interface SlackOptionsMiddlewareArgs<Source extends OptionsSource = OptionsSource> {
-  payload: OptionsRequest<Source>;
+  payload: OptionsPayloadFromType<Source>;
   body: this['payload'];
   options: this['payload'];
   ack: OptionsAckFn<Source>;
 }
 
 /**
- * A request for options for a select menu with an external data source, wrapped in the standard metadata. The menu
- * can have a source of Slack's Block Kit external select elements, dialogs, or legacy interactive components.
- *
- * This describes the entire JSON-encoded body of a request.
+ * All sources from which Slack sends options requests.
  */
-export interface OptionsRequest<Source extends OptionsSource = OptionsSource> extends StringIndexed {
+export type OptionsSource = 'interactive_message' | 'dialog_suggestion' | 'block_suggestion';
+
+export type SlackOptions = BlockSuggestion | InteractiveMessageSuggestion | DialogSuggestion;
+
+export interface BasicOptionsPayload<Type extends string = string> {
+  type: Type;
   value: string;
-  type: Source;
+}
+
+type OptionsPayloadFromType<T extends string> = KnownOptionsPayloadFromType<T> extends never
+  ? BasicOptionsPayload<T>
+  : KnownOptionsPayloadFromType<T>;
+
+type KnownOptionsPayloadFromType<T extends string> = Extract<SlackOptions, { type: T }>;
+
+/**
+ * external data source in blocks
+ */
+export interface BlockSuggestion extends StringIndexed {
+  type: 'block_suggestion';
+  block_id: string;
+  action_id: string;
+  value: string;
+
+  api_app_id: string;
   team: {
     id: string;
     domain: string;
-    enterprise_id?: string; // undocumented
-    enterprise_name?: string; // undocumented
+    enterprise_id?: string;
+    enterprise_name?: string;
   } | null;
   channel?: {
     id: string;
@@ -34,25 +54,48 @@ export interface OptionsRequest<Source extends OptionsSource = OptionsSource> ex
   user: {
     id: string;
     name: string;
-    team_id?: string; // undocumented
+    team_id?: string;
   };
-  token: string;
-
-  name: Source extends 'interactive_message' | 'dialog_suggestion' ? string : never;
-  callback_id: Source extends 'interactive_message' | 'dialog_suggestion' ? string : never;
-  action_ts: Source extends 'interactive_message' | 'dialog_suggestion' ? string : never;
-
-  message_ts: Source extends 'interactive_message' ? string : never;
-  attachment_id: Source extends 'interactive_message' ? string : never;
-
-  api_app_id: Source extends 'block_suggestion' ? string : never;
-  action_id: Source extends 'block_suggestion' ? string : never;
-  block_id: Source extends 'block_suggestion' ? string : never;
-  container: Source extends 'block_suggestion' ? StringIndexed : never;
+  token: string; // legacy verification token
+  container: StringIndexed;
+  // exists for blocks in either a modal or a home tab
+  view?: ViewOutput;
+  // exists for enterprise installs
+  is_enterprise_install?: boolean;
+  enterprise?: {
+    id: string;
+    name: string;
+  };
+}
 
-  // this appears in the block_suggestions schema, but we're not sure when its present or what its type would be
-  app_unfurl?: any;
+/**
+ * external data source in attachments
+ */
+export interface InteractiveMessageSuggestion extends StringIndexed {
+  type: 'interactive_message';
+  name: string;
+  value: string;
+  callback_id: string;
+  action_ts: string;
+  message_ts: string;
+  attachment_id: string;
 
+  team: {
+    id: string;
+    domain: string;
+    enterprise_id?: string;
+    enterprise_name?: string;
+  } | null;
+  channel?: {
+    id: string;
+    name: string;
+  };
+  user: {
+    id: string;
+    name: string;
+    team_id?: string;
+  };
+  token: string; // legacy verification token
   // exists for enterprise installs
   is_enterprise_install?: boolean;
   enterprise?: {
@@ -62,9 +105,38 @@ export interface OptionsRequest<Source extends OptionsSource = OptionsSource> ex
 }
 
 /**
- * All sources from which Slack sends options requests.
+ * external data source in dialogs
  */
-export type OptionsSource = 'interactive_message' | 'dialog_suggestion' | 'block_suggestion';
+export interface DialogSuggestion extends StringIndexed {
+  type: 'dialog_suggestion';
+  name: string;
+  value: string;
+  callback_id: string;
+  action_ts: string;
+
+  team: {
+    id: string;
+    domain: string;
+    enterprise_id?: string;
+    enterprise_name?: string;
+  } | null;
+  channel?: {
+    id: string;
+    name: string;
+  };
+  user: {
+    id: string;
+    name: string;
+    team_id?: string;
+  };
+  token: string; // legacy verification token
+  // exists for enterprise installs
+  is_enterprise_install?: boolean;
+  enterprise?: {
+    id: string;
+    name: string;
+  };
+}
 
 /**
  * Type function which given an options source `Source` returns a corresponding type for the `ack()` function. The
@@ -96,3 +168,56 @@ export interface OptionGroups<Options> {
     label: string;
   } & Options)[];
 }
+
+// Don't delete the following interface for backward-compatibility
+// We may remove it in v4 or newer
+
+/**
+ * A request for options for a select menu with an external data source, wrapped in the standard metadata. The menu
+ * can have a source of Slack's Block Kit external select elements, dialogs, or legacy interactive components.
+ *
+ * This describes the entire JSON-encoded body of a request.
+ * @deprecated You can use more specific types such as BlockSuggestionPayload
+ */
+export interface OptionsRequest<Source extends OptionsSource = OptionsSource> extends StringIndexed {
+  value: string;
+  type: Source;
+  team: {
+    id: string;
+    domain: string;
+    enterprise_id?: string; // undocumented
+    enterprise_name?: string; // undocumented
+  } | null;
+  channel?: {
+    id: string;
+    name: string;
+  };
+  user: {
+    id: string;
+    name: string;
+    team_id?: string; // undocumented
+  };
+  token: string;
+
+  name: Source extends 'interactive_message' | 'dialog_suggestion' ? string : never;
+  callback_id: Source extends 'interactive_message' | 'dialog_suggestion' ? string : never;
+  action_ts: Source extends 'interactive_message' | 'dialog_suggestion' ? string : never;
+
+  message_ts: Source extends 'interactive_message' ? string : never;
+  attachment_id: Source extends 'interactive_message' ? string : never;
+
+  api_app_id: Source extends 'block_suggestion' ? string : never;
+  action_id: Source extends 'block_suggestion' ? string : never;
+  block_id: Source extends 'block_suggestion' ? string : never;
+  container: Source extends 'block_suggestion' ? StringIndexed : never;
+
+  // this appears in the block_suggestions schema, but we're not sure when its present or what its type would be
+  app_unfurl?: any;
+
+  // exists for enterprise installs
+  is_enterprise_install?: boolean;
+  enterprise?: {
+    id: string;
+    name: string;
+  };
+}
diff --git a/src/types/view/index.ts b/src/types/view/index.ts
index 1767419..9acfaa0 100644
--- a/src/types/view/index.ts
+++ b/src/types/view/index.ts
@@ -25,7 +25,7 @@ export interface SlackViewMiddlewareArgs<ViewActionType extends SlackViewAction
 interface PlainTextElementOutput {
   type: 'plain_text';
   text: string;
-  emoji: boolean;
+  emoji?: boolean;
 }
 
 /**


Test patch:
diff --git a/types-tests/options.test-d.ts b/types-tests/options.test-d.ts
index 8bfc2b1..b2e641c 100644
--- a/types-tests/options.test-d.ts
+++ b/types-tests/options.test-d.ts
@@ -1,63 +1,77 @@
-import { expectType, expectNotType, expectError } from 'tsd';
-import { App, OptionsRequest, OptionsSource } from '../dist';
+import { expectType, expectError } from 'tsd';
+import {
+  App,
+  SlackOptions,
+  BlockSuggestion,
+  InteractiveMessageSuggestion,
+  DialogSuggestion,
+} from '../dist';
 import { Option } from '@slack/types';
 
 const app = new App({ token: 'TOKEN', signingSecret: 'Signing Secret' });
 
 const blockSuggestionOptions: Option[] = [
   {
-    "text": {
-      "type": "plain_text",
-      "text": "foo"
+    text: {
+      type: 'plain_text',
+      text: 'foo',
     },
-    "value": "bar"
-  }
+    value: 'bar',
+  },
 ];
 
 // set the default to block_suggestion
-expectType<void>(app.options('action-id-or-callback-id', async ({ options, ack }) => {
-  expectType<OptionsRequest<'block_suggestion'>>(options);
-  expectType<never>(options.callback_id);
-  options.block_id;
-  options.action_id;
-  // https://github.com/slackapi/bolt-js/issues/720
-  await ack({ options: blockSuggestionOptions });
-  await Promise.resolve(options);
-}));
+expectType<void>(
+  app.options('action-id-or-callback-id', async ({ options, ack }) => {
+    expectType<BlockSuggestion>(options);
+    // resolved by StringIndexed
+    expectType<any>(options.callback_id);
+    options.block_id;
+    options.action_id;
+    // https://github.com/slackapi/bolt-js/issues/720
+    await ack({ options: blockSuggestionOptions });
+    await Promise.resolve(options);
+  }),
+);
 
 // block_suggestion
-expectType<void>(app.options<'block_suggestion'>({ action_id: 'a' }, async ({ options, ack }) => {
-  expectNotType<OptionsRequest<OptionsSource>>(options);
-  expectType<OptionsRequest<'block_suggestion'>>(options);
-  // https://github.com/slackapi/bolt-js/issues/720
-  await ack({ options: blockSuggestionOptions });
-  await Promise.resolve(options);
-}));
+expectType<void>(
+  app.options<'block_suggestion'>({ action_id: 'a' }, async ({ options, ack }) => {
+    expectType<BlockSuggestion>(options);
+    // https://github.com/slackapi/bolt-js/issues/720
+    await ack({ options: blockSuggestionOptions });
+    await Promise.resolve(options);
+  }),
+);
 // FIXME: app.options({ type: 'block_suggestion', action_id: 'a' } does not work
 
 // interactive_message (attachments)
-expectType<void>(app.options<'interactive_message'>({ callback_id: 'a' }, async ({ options, ack }) => {
-  expectNotType<OptionsRequest<OptionsSource>>(options);
-  expectType<OptionsRequest<'interactive_message'>>(options);
-  // https://github.com/slackapi/bolt-js/issues/720
-  expectError(ack({ options: blockSuggestionOptions }));
-  await Promise.resolve(options);
-}));
+expectType<void>(
+  app.options<'interactive_message'>({ callback_id: 'a' }, async ({ options, ack }) => {
+    expectType<InteractiveMessageSuggestion>(options);
+    // https://github.com/slackapi/bolt-js/issues/720
+    expectError(ack({ options: blockSuggestionOptions }));
+    await Promise.resolve(options);
+  }),
+);
 
-expectType<void>(app.options({ type: 'interactive_message', callback_id: 'a' }, async ({ options, ack }) => {
-  // FIXME: the type should be OptionsRequest<'interactive_message'>
-  expectType<OptionsRequest<OptionsSource>>(options);
-  // https://github.com/slackapi/bolt-js/issues/720
-  expectError(ack({ options: blockSuggestionOptions }));
-  await Promise.resolve(options);
-}));
+expectType<void>(
+  app.options({ type: 'interactive_message', callback_id: 'a' }, async ({ options, ack }) => {
+    // FIXME: the type should be OptionsRequest<'interactive_message'>
+    expectType<SlackOptions>(options);
+    // https://github.com/slackapi/bolt-js/issues/720
+    expectError(ack({ options: blockSuggestionOptions }));
+    await Promise.resolve(options);
+  }),
+);
 
 // dialog_suggestion (dialog)
-expectType<void>(app.options<'dialog_suggestion'>({ callback_id: 'a' }, async ({ options, ack }) => {
-  expectNotType<OptionsRequest<OptionsSource>>(options);
-  expectType<OptionsRequest<'dialog_suggestion'>>(options);
-  // https://github.com/slackapi/bolt-js/issues/720
-  expectError(ack({ options: blockSuggestionOptions }));
-  await Promise.resolve(options);
-}));
+expectType<void>(
+  app.options<'dialog_suggestion'>({ callback_id: 'a' }, async ({ options, ack }) => {
+    expectType<DialogSuggestion>(options);
+    // https://github.com/slackapi/bolt-js/issues/720
+    expectError(ack({ options: blockSuggestionOptions }));
+    await Promise.resolve(options);
+  }),
+);
 // FIXME: app.options({ type: 'dialog_suggestion', callback_id: 'a' } does not work