【Electron】MenuItemConstructorOptionsの型推論

初書：2021/10/14
mac : 11.6
electron : 15.1.2
typescript : 4.4.3

# 前置き

electronとtypescriptを使ってアプリケーションを開発中のこと。
メニューバーを作成しようと試みたときに起きた出来事。

「型推論が通らない」

ので、それの修正メモ。

# 該当コード

該当ページ[Menu | Electron](https://www.electronjs.org/ja/docs/latest/api/menu)

Electronの日本語公式ページにある「サンプル」コードで問題は起きた。
ちなみにそのコードを、Typescript用に若干書き換えたものがこちら。

```ts
import { app, Menu } from ('electron');

const isMac = process.platform === 'darwin';

const template = [
  // { role: 'appMenu' }
  ...(isMac ? [{
    label: app.name,
    submenu: [
      { role: 'about' },
      { type: 'separator' },
      { role: 'services' },
      { type: 'separator' },
      { role: 'hide' },
      { role: 'hideOthers' },
      { role: 'unhide' },
      { type: 'separator' },
      { role: 'quit' }
    ]
  }] : []),
  // { role: 'fileMenu' }
  {
    label: 'ファイル',
    submenu: [
      isMac ? { role: 'close' } : { role: 'quit' }
    ]
  },
  // { role: 'editMenu' }
  {
    label: 'Edit',
    submenu: [
      { role: 'undo' },
      { role: 'redo' },
      { type: 'separator' },
      { role: 'cut' },
      { role: 'copy' },
      { role: 'paste' },
      ...(isMac ? [
        { role: 'pasteAndMatchStyle' },
        { role: 'delete' },
        { role: 'selectAll' },
        { type: 'separator' },
        {
          label: 'Speech',
          submenu: [
            { role: 'startSpeaking' },
            { role: 'stopSpeaking' }
          ]
        }
      ] : [
        { role: 'delete' },
        { type: 'separator' },
        { role: 'selectAll' }
      ])
    ]
  },
  // { role: 'viewMenu' }
  {
    label: 'View',
    submenu: [
      { role: 'reload' },
      { role: 'forceReload' },
      { role: 'toggleDevTools' },
      { type: 'separator' },
      { role: 'resetZoom' },
      { role: 'zoomIn' },
      { role: 'zoomOut' },
      { type: 'separator' },
      { role: 'togglefullscreen' }
    ]
  },
  // { role: 'windowMenu' }
  {
    label: 'Window',
    submenu: [
      { role: 'minimize' },
      { role: 'zoom' },
      ...(isMac ? [
        { type: 'separator' },
        { role: 'front' },
        { type: 'separator' },
        { role: 'window' }
      ] : [
        { role: 'close' }
      ])
    ]
  },
  {
    role: 'help',
    submenu: [
      {
        label: 'Learn More',
        click: async () => {
          const { shell } = require('electron')
          await shell.openExternal('https://electronjs.org')
        }
      }
    ]
  }
];

const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
```

requireがimportになって個人的に気に入らなかったセミコロンがついただけ。

ここで下から２行目の`Menu.buildFromTemplate(template);`のtemplateにエラーが付きます。

# エラーの原因

まずはエラー文はこちら。

```txt
型 '({ label: string; submenu: ({ role: string; type?: undefined; label?: undefined; submenu?: undefined; } | { type: string; role?: undefined; label?: undefined; submenu?: undefined; } | { label: string; submenu: { ...; }[]; role?: undefined; type?: undefined; })[]; role?: undefined; } | { ...; })[]' の引数を型 '(MenuItemConstructorOptions | MenuItem)[]' のパラメーターに割り当てることはできません。
  型 '{ label: string; submenu: ({ role: string; type?: undefined; label?: undefined; submenu?: undefined; } | { type: string; role?: undefined; label?: undefined; submenu?: undefined; } | { label: string; submenu: { ...; }[]; role?: undefined; type?: undefined; })[]; role?: undefined; } | { ...; }' を型 'MenuItemConstructorOptions | MenuItem' に割り当てることはできません。
    型 '{ label: string; submenu: ({ role: string; type?: undefined; label?: undefined; submenu?: undefined; } | { type: string; role?: undefined; label?: undefined; submenu?: undefined; } | { label: string; submenu: { ...; }[]; role?: undefined; type?: undefined; })[]; role?: undefined; }' を型 'MenuItemConstructorOptions | MenuItem' に割り当てることはできません。
      型 '{ label: string; submenu: ({ role: string; type?: undefined; label?: undefined; submenu?: undefined; } | { type: string; role?: undefined; label?: undefined; submenu?: undefined; } | { label: string; submenu: { ...; }[]; role?: undefined; type?: undefined; })[]; role?: undefined; }' には 型 'MenuItem' からの次のプロパティがありません: checked, click, commandId, enabled、9 など。ts(2345)
```

読む気失せるほど長い。
単純にいうと、`type`や`role`がstring型になっているが、`MenuItemConstructorOptions`内ではストリングリテラル型(限定されたstring型)だよーというエラー。

これは、`template`を定義する際に型指定をしていないため、単にstring型として認識されているのが原因。
ということで、`template`に型をつける。

```ts
const template : Electron.MenuItemConstructorOptions[] = [
    // 略
```

はい、終わりです。エラーの量が。

```ts
...(isMac ? [] : []) // 上記コードの一部を切り取っているだけなので、当たり前ですが空の配列ではないです。
```
この辺が全部エラー吐きます。

# 解決策を考える

おそらく三項演算子を使っているため、その箇所で型推論していないのが原因なのではないかと予想し、

```ts
// 上記と同じエラー
...(isMac ? [] as Electron.MenuItemConstructorOptions[]: [])
// 型 'MenuItemConstructorOptions' には、反復子を返す '[Symbol.iterator]()' メソッドが必要です。ts(2488)
...(isMac ? [] : []) as Electron.MenuItemConstructorOptions[]
```

いずれもダメ。

仕方ないので外部に切り出す。

```ts
let NameSubMenu : Electron.MenuItemConstructorOptions[] = [];
if(isMac){
  NameSubMenu = [
    {
      label: app.name,
      submenu: [
        { role: 'about' },
        { type: 'separator' },
        { role: 'services' },
        { type: 'separator' },
        { role: 'hide' },
        { role: 'hideOthers' },
        { role: 'unhide' },
        { type: 'separator' },
        { role: 'quit' }
      ]
    }
  ];
}

const template : Electron.MenuItemConstructorOptions[] = [
  // { role: 'appMenu' }
  ...NameSubMenu ,
  // 以下略
```

これも結局エラーが出るので意味なし。

# 結論

誰かに頼る。
[Create Electron Menu in TypeScript? - Stack Overflow](https://stackoverflow.com/questions/45811603/create-electron-menu-in-typescript)

pushにするといいとかなんとか。
ということで仕方なく全部pushに書き換えた。

```ts
const template: Electron.MenuItemConstructorOptions[] = [];

const macMenu: Electron.MenuItemConstructorOptions[] = [
  {
    label: app.name,
    submenu: [
      { role: 'about' },
      { type: 'separator' },
      { role: 'services' },
      { type: 'separator' },
      { role: 'hide' },
      { role: 'hideOthers' },
      { role: 'quit' },
    ],
  },
];

if (isMac) {
  // { role: 'appMenu' }
  template.push(...macMenu);
}
// { role: 'fileMenu' }
template.push({
  label: 'ファイル',
  submenu: [isMac ? { role: 'close' } : { role: 'quit' }],
});

// { role: 'editMenu' }
const editSubMenu: Electron.MenuItemConstructorOptions[] = [
  { role: 'undo' },
  { role: 'redo' },
  { type: 'separator' },
  { role: 'cut' },
  { role: 'copy' },
  { role: 'paste' },
];

if (isMac) {
  editSubMenu.push(
    ...([
      { role: 'pasteAndMatchStyle' },
      { role: 'delete' },
      { role: 'selectAll' },
      { type: 'separator' },
      {
        label: 'Speech',
        submenu: [{ role: 'startSpeaking' }, { role: 'stopSpeaking' }],
      },
    ] as Electron.MenuItemConstructorOptions[])
  );
} else {
  editSubMenu.push(
    ...([
      { role: 'delete' },
      { type: 'separator' },
      { role: 'selectAll' },
    ] as Electron.MenuItemConstructorOptions[])
  );
}

template.push({
  label: 'Edit',
  submenu: editSubMenu,
});

// { role: 'viewMenu' }
template.push({
  label: 'View',
  submenu: [
    { role: 'reload' },
    { role: 'forceReload' },
    { role: 'toggleDevTools' },
    { type: 'separator' },
    { role: 'resetZoom' },
    { role: 'zoomIn' },
    { role: 'zoomOut' },
    { type: 'separator' },
    { role: 'togglefullscreen' },
  ],
});

// { role: 'windowMenu' }

const windowSubMenu: Electron.MenuItemConstructorOptions[] = [
  { role: 'minimize' },
  { role: 'zoom' },
];

if (isMac) {
  windowSubMenu.push(
    ...([
      { type: 'separator' },
      { role: 'front' },
      { type: 'separator' },
      { role: 'window' },
    ] as Electron.MenuItemConstructorOptions[])
  );
} else {
  windowSubMenu.push({ role: 'close' });
}

template.push({
  label: 'Window',
  submenu: windowSubMenu,
});

template.push({
  role: 'help',
  submenu: [
    {
      label: 'Learn More',
      click: async () => {
        const { shell } = require('electron');
        await shell.openExternal('https://electronjs.org');
      },
    },
  ],
});

const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
```

いや何かもう少しいい方法が存在している気がしてならない。でもこれ以上思いつきませんでした。
何かいい案あれば教えてください。
それではー