TypeScriptでカスタムイベントの型定義をする方法

はいさい！ちゅらデータぬオースティンやいびーん！

# 概要

TypeScriptでカスタムイベント（CustomEvent）の型を定義する方法を紹介します。

CustomEvent定義が必要な要素は以下の三つかと思います。

- `Window`
- `Document`
- `HTMLElement`

それぞれの`EventEmitter`のTypeScriptのデフォルトのEvent定義を拡張する方法を解説していきましょう！

# WindowのCustomEventを登録する

まず、`Window`に対してCustomEventを配信するようなコードがあった場合の解説をします。

`Window`が配信元の場合、`WindowEventMap`というTypeScriptの中に入っている定義を拡張する必要があります。

https://microsoft.github.io/PowerBI-JavaScript/interfaces/_node_modules_typedoc_node_modules_typescript_lib_lib_dom_d_.windoweventmap.html

拡張方法は`declare global`構文になります。

```typescript
export const emitUserLoginEvent = (uid: string) => {
    const event = new CustomEvent("user-login", { detail: uid });
    window.dispatchEvent(event);
}

declare global {
    interface WindowEventMap {
        "user-login": CustomEvent<string>;
  }
}
```

こうすると、以下のように`Window`に`addEventListener`を実行すると、`CustomEvent`の`"user-login"`が自動で推測される上、`EventListener`の`event.detail`も文字列型だと推測されます。

![スクリーンショット 2022-10-06 14.42.24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/4de5dc41-0764-2f10-cf29-095b2a6ca7ad.png)

定義しているファイル以外にもちゃんと拾われます。

```typescript
window.addEventListener("user-login", ({ detail }) => {
    console.log(detail) // string
})
```

通常であれば、ここでTypeScriptのインタプリーターが文句を言い始めるのですが、きちんと`WindowEventMap`を拡張していれば納得してくれます。

TSプレーグラウンドでもご確認いただけます。

https://www.typescriptlang.org/play?#code/KYDwDg9gTgLgBAYwgOwM72AWwJYwKqrBQAyEA5tsgKIBuwy8AvHABQCu2AJgFxzpSUyASjiMAfHADeAKDhzEKdHGB0GouMmAB3OAGE26CJlr0YLAEQGiAWgA25SuYA0UuJ2AwAhtlu8OnOABfIQBuWXktSk4ILQA6TmxUME8YBAALEwYWFVNQ6UDpaXcEW08oYDgyewAjT1spcLlKGCIAM08ECoB1KJjMmABZTzAG+TG4S0IoOwdkc159Q2NVGAAefkExMLkCgulI5Gi4z05OfuJEls0oCytp+wo5lxZJNw9veuDRCRkxpDQILZgLEHix3F4fCIAPRQvgwATIMj5ITSIA

# DocumentのCustomEventを定義する方法

上記と似たような手法になりますが、今回は`DocumentEventMap`という`interface`を拡張します。

https://microsoft.github.io/PowerBI-JavaScript/interfaces/_node_modules_typedoc_node_modules_typescript_lib_lib_dom_d_.documenteventmap.html

非常に近いコードになりますが、以下の通りです。

```typescript
export const emitUserLoginEvent = (uid: string) => {
    const event = new CustomEvent("user-login", { detail: uid });
    document.dispatchEvent(event);
}

declare global {
    interface DocumentEventMap {
        "user-login": CustomEvent<string>;
  }
}
```

こちらもTSプレーグラウンドでご確認いただけます。

https://www.typescriptlang.org/play?ssl=10&ssc=2&pln=1&pc=1#code/KYDwDg9gTgLgBAYwgOwM72AWwJYwKqrBQAyEA5tsgKIBuwy8AvHABQCu2AJgFxzpSUyASjiMAfHADeAKDhzEKdHGB0GouMmAB3OAGE26CJlr0YLAEQGiAWgA25SuYA0UuJ2AwAhtlu8OnOABfIQBuWXlOCAQ2TFMAOk5sVDBPGAQACxMGFhVTUOlA6Wl3BFtPKGA4MnsAI09bKXC5ShgiADNPBEqAESiY0yyYAFlPMEb5CbhLQig7B2RzXn1DY1UYAB5+QTEwuULC4r7YhjjPTk5B4iTWzSgLK1n7CgWXFkk3D28G4NEJGQmkGgILZgHEnix3F4fCIAPQwvgwATIMgFITSIA

# HTMLElementのCustomEventを定義する方法

最後に、こちらは筆者にとっては結構重要なものですが、`HTMLElement`から配信されるCustomEventも定義できます。

`HTMLElement`というと、全てのHTML要素になってしまうのですが、`HTMLElement`を継承して定義されるWeb Componentsもこの手法で定義できるのです。

Web ComponentでCustomEventを配信することも多いかと思いますが、以下のやり方で型定義ができます。

以下のようなWeb ComponentがDOMにあるとしましょう。

```typescript:login-form.ts
export const tagName = "login-form";

export class LoginForm extends HTMLElement {
    form!: HTMLFormElement;

    connectedCallback() {
        this.innerHTML = `
        <form>
            <input name="username">
            <input name="password">
        <form>`
        this.form = this.querySelector("form")!;
        this.form.addEventListener("submit", this.handleFormSubmission);
    }

    private handleFormSubmission: EventListener = (event) => {
        event.preventDefault();
        new Promise((resolve, reject) => {
            // 認証ロジック
            resolve("uid");
        }).then((uid) => {
            const event = new CustomEvent("user-login", { detail: uid, bubbles: false });
            this.dispatchEvent(event);
        })
    }
}

if (!window.customElements.get(tagName)) {
    window.customElements.define(tagName, LoginForm);
}
```

ログインしたら、`<login-form>`から`"user-login"`のイベントが配信されるようになっています。

すると、以下のような`declare global`構文を追加すると、違うスクリプトでも型推測ができます。

```typescript:login-form.ts
declare global {
    interface HTMLElementTagNameMap {
        [tagName]: LoginForm;
    }
    interface HTMLElementEventMap {
        "user-login": CustomEvent<string>;
    }
}
```

`HTMLElementTagNameMap`も拡張していますが、これは`<login-form>`を一つの`HTMLElement`としてTypeScriptのインタープリターに知らせる、つまり型定義するためです。

違うスクリプトの`index.ts`で以下のようなコードが問題なく書けます。

```typescript:index.ts
import "./login-form.js";

const loginForm = document.createElement(tagName);
loginForm.addEventListener("user-login", ({ detail }) => {
    console.log(detail) // string
})
```

# まとめ

以上、`CustomEvent`のTypeScriptにおける型定義の方法を紹介してまいりました。

特にWeb Componentを書くときに、こういう`declare global`構文を追加すると、アプリケーション全体の型推測が良くなり、開発がスムーズになるかと思います。

また、Web ComponentsとVueを一緒に使っている時もCustomEventsを使って組み込みますが、型定義をしていると円滑に導入できます。

脱線ですが、実際、Vueは`CustomEvent`を使っているのですよ？