🐥

型付きEventTarget、TypedEventTargetを作りました

に公開

誰もが一度は自作するEventTarget。私も頑張って作ってはみるものの、型情報がついてたりついてなかったり、addEventListeneronと短縮してみたりしなかったり、なかなかしっくりくるものが作れず、中途半端なものを作って使っていました。いい加減ちゃんとしたものが欲しくなったので、TypeScriptの型システムを調べつつ作ってみました。

満たしたい条件

  • ちゃんとしたやつ。
    • 可能な限り既存の仕組みを利用している。
    • イベント送信元(Event.curentTarget)が型情報を持っている。
    • イベントの付加情報(CustomEvent.detail)も型情報を持っている。
    • イベントの種類に応じたメソッドオーバーロードが、addEventListener, removeEventListener, dispathEventのそれぞれに定義され、コード補完が効く。
  • 最小限の定義(イベント名の文字列と付加情報の型)で利用できる。

できたもの

できました。以下が型定義の全てです。コピペしてお使いください。ちなみに、筆者作の分散共有アプリ向けミドルウェア: Madoiクライアントライブラリにも同じものが入っています。

export interface TypedCustomEvent<T extends EventTarget, D>
extends CustomEvent<D>{
  currentTarget: T;
  detail: D;
}

export interface TypedEventListener<T extends EventTarget, D>{
  (this: T, evt: TypedCustomEvent<T, D>): void;
}

export interface TypedEventListenerObject<
T extends EventTarget, D>{
  handleEvent(evt: TypedCustomEvent<T, D>): void;
}

export type TypedEventListenerOrEventListenerObject<
T extends EventTarget, D> =
  TypedEventListener<T, D> | TypedEventListenerObject<T, D>;

export class TypedEventTarget<
  T extends TypedEventTarget<T, Events>,
  Events extends Record<string, any>>
  extends EventTarget {
  addEventListener<K extends keyof Events>(
    type: K,
    listener: TypedEventListenerOrEventListenerObject<T, Events[K]> | null,
    options?: AddEventListenerOptions | boolean): void;
  addEventListener(...args: Parameters<EventTarget["addEventListener"]>){
    super.addEventListener(...args);
  }
  removeEventListener<K extends keyof Events>(
    type: K,
    listener: TypedEventListenerOrEventListenerObject<T, Events[K]> | null,
    options?: EventListenerOptions | boolean): void;
  removeEventListener(...args: Parameters<EventTarget["removeEventListener"]>){
    super.removeEventListener(...args);
  }
  dispatchCustomEvent<K extends keyof Events>(
    type: K, detail?: Events[K]): boolean;
  dispatchCustomEvent(type: string, detail: any){
    return super.dispatchEvent(new CustomEvent(type, {detail}));
  }
}

イベントの登録と削除に使うメソッドはaddEventListenerremoveEventListenerと、EventTargetのものと同じ名前にしてあります。

イベントを発生させるメソッド(EventTargetではdispatchEvent(evt: Event))では、簡素化のため、引数をtypedetailの2つにしたいと考えました。しかしそれだとdispatchEvent(evt: Event)とは引数が違うので、名前を変え、dispatchCustomEventとしました。結果的に実装内でCustomEventを使っていることも示せたのでヨシ。

使い方

テキストから画像を生成するクラスを例に、使い方を示します。

// イベントを発生させる側のコード
// ①イベント付加情報の型定義
export interface GenerationResultDetail{
    text: string;
    dataUrl: string;
}

// ②イベントリスナーの型定義。無くても良いですが、あると使う側でリスナを独立して宣言したい時に便利です。
export type GenerationResultListener =
  TypedEventListenerOrListenerObject<TextToImageGenerator, GenerationResultDetail>;

// ③イベントを発生させるクラスの定義。
// TypedEventTarget(今回作ったクラス)を継承し、2つ目の
// 型パラメータでイベントの種類と付加情報を定義したクラスを指定(複数可)
// すると、定義に応じたaddEventTarget等のメソッドが利用できます。
export class TextToImageGenerator
extends TypedEventTarget<TextToImageGenerator, {
  generationResult: GenerationResultDetail
}>{
  generate(text: string){
    const result = this.doGenerate(text);
    // ④イベントを発生させるコード。dispatchCustomEventは補完が効きます
    this.dispatchCustomEvent("generationResult",
      {text, dataUrl: result});
...
}

// ⑤イベントを受け取る側のコード
const gen: TextToImageGenerator = ...
// addEventListenerは補完が効きます
gen.addEventListener("generationResult", ({detail})=>{
  console.log(detail.text);
  imgTag.src = dataUrl;
});

上記①。イベントを発生させる側では、まず発生させるイベントの付加情報を定義します。イベント名 + Detail という名前のインタフェースにしておくとわかりやすくて良いでしょう(上記コード例ではGenerationResultDetail)。この際、イベントリスナのクラス定義(上記②)もあると親切です。const listener: GenerationResultListner = ({detail})=>...というように、イベントリスナを型付きで定義するのが楽になります。

上記③。イベントを発生させるクラスを、TypedEventTargetから継承する形で定義します。TypedEventTargetは2つの型パラメータを持ちます。一つ目は継承先のクラス自身(これがイベントのcurrentTargetの型として使われます)、二つ目は発生させるイベントとその付加情報の定義です。二つ目の型パラメータから定義情報が抽出され、定義に応じたaddEventListener, removeEventListener, dispatchCustomEventメソッドがあるように見えるようになります(正確には、オーバーロードメソッドが存在するように見えます)。

上記④。イベントを発生させたい時は、this.dispatchCustomEventメソッドを呼び出します。一つ目の引数はイベントの種類(TypedEventTargetクラスの2つ目の型パラメータで渡した定義のどれか)、二つ目の引数は、イベントの付加情報(detail)です。dispatchCustomEventの実装内で、CustomEventクラスが作成され、EventTargetdispatchEventメソッドに渡されます。

上記⑤。イベントを受け取る側は、addEventListenerremoveEventListenerメソッドを利用します。これらのメソッドは通常のEventTargetのものと同じように使えますが、イベントのタイプ毎にオーバーロードが定義されるため、補完と型チェックが効きます。便利。

詳しい説明

ここからは、今回作ったTypedEventListenerがどういう仕組みを使っているかの説明になります。TypeScriptが持つ高度で強力な型システムの話も出てきて、難解な箇所もあります。便利な仕組みの裏側を知りたい人や、型の深淵を覗きたい人向けです。

前提知識

TypedEventTargetの説明をする前に、前提知識として、イベントの基礎知識と、TypeScriptでのイベント定義例を見ていきます。

イベントの基礎知識

まずはTypeScriptにおけるイベントをおさらいします。イベント周りの基礎知識は以下です。

  • 独自のイベントを扱うときは、EventTargetクラスを使う。
  • イベントを発生させる側はEventTargetを継承し、イベントを発生させたい箇所でEventクラスかその派生クラスであるCustomEventクラスをnewして、EventTarget.dispatchEventメソッドに渡す。
    • 例: dispatchEvent(new CustomEvent("type", {detail: ???}))
  • イベントを受け取る側は、あらかじめ、受け取りたいtypeとイベントリスナをEventTarget.addEventListenerメソッドで登録しておく。
    • 例: addEventListener("type", e => { /* e.currentTargetが送信元、e.detailが付加情報を示す */ })
  • CustomEventクラスは、detailの型を型パラメータとして受け取ります。
    • 定義: interface CustomEvent<T = any> extends Event ...
  • currentTargetの型はパラメータにはなっておらず、EventTarget(又はnull)です。
    • 定義: interface Event { readonly currentTarget: EventTarget | null; ...}
  • 参考: MDN: イベントの作成と起動

基本的にはJavaScriptで定義されている定義をそのまま踏襲し、CustomEventに型パラメータが追加されているという状態です。

TypeScriptにおけるイベントの実装(HTMLDivElementの例)

DOM要素のイベントを見ていきます。HTMLDivElementなど、HTML要素に対応するクラスは、継承元にEventTargetクラスを持っており、イベントを扱います。HTMLDivElementのTypeScriptでの定義の一部を見てみましょう。なお、コメントは著者によるものです。

interface HTMLDivElement extends HTMLElement {
  // メソッドのオーバーロード定義
  addEventListener<K extends keyof HTMLElementEventMap>(
    type: K, listener: (this: HTMLDivElement, ev: HTMLElementEventMap[K])
      => any, options?: boolean | AddEventListenerOptions): void;
  // メソッドの実体
  addEventListener(type: string,
    listener: EventListenerOrEventListenerObject, options?: boolean |
    AddEventListenerOptions): void;
  ...

addEventListnerは、このクラスから発生させる各イベントに対応したオーバーロードが定義されています。TypeScriptにおけるオーバーロードは、メソッドの実態が一つと、オーバーロードを表現するメソッドの定義が複数という構成を取ります。上記の例では、最初の定義がオーバーロードの型定義で、2番目の定義が実体です。最初のオーバーロードの定義では、メソッドの型パラメータとして、以下が定義されています。

<K extends keyof HTMLElementEventMap>

これは、HTMLElementEventMapクラスの定義内のキー(例えば{key1: value1, key2: value2}と定義されていれば、key1key2がキー)を型パラメータとして受け付けると言う意味です。次に、addEventListenerの2つ目の引数の型で、以下の定義が使用されています。

HTMLElementEventMap[K]

これは、HTMLElementEventMapクラスのキーKに対応する値(上述の{key1: valu1, key2: value2}value1value2)を参照するという意味です。ではHTMLElementEventMapの定義を見てみましょう。

interface HTMLElementEventMap extends ElementEventMap, GlobalEventHandlersEventMap {
}

interface GlobalEventHandlersEventMap {
  ...
  "keydown": KeyboardEvent;
  "keypress": KeyboardEvent;
  "keyup": KeyboardEvent;
  ...
  "mousedown": MouseEvent;
  "mouseenter": MouseEvent;
  "mouseleave": MouseEvent;
  "mousemove": MouseEvent;
  ...
}

HTMLElementEventMap自体は中身が無いので、継承元のGlobalEventHandlersEventMapの定義も持ってきました。そこでは、お馴染みのイベント、keydownmousedownといったものが、イベントクラスとともに定義されています。先ほどの型パラメータでは、Kの部分はこのkeydownmousedownといった文字列になります。つまり先ほどのaddEventListenerの定義は、以下のように書いているのと同じです。

interface HTMLDivElement extends HTMLElement {
  // メソッドのオーバーロード定義
  addEventListener(
    type: "keydown", listener: (this: HTMLDivElement, ev: KeyboardEvent)
      => any, options?: boolean | AddEventListenerOptions): void;
  addEventListener(
    type: "mousedown", listener: (this: HTMLDivElement, ev: MouseEvent)
      => any, options?: boolean | AddEventListenerOptions): void;
  // メソッドの実体
  ...
  addEventListener(type: string,
    listener: EventListenerOrEventListenerObject, options?: boolean |
    AddEventListenerOptions): void;
  ...

上記では、メソッドのオーバーロード定義を、HTMLElementEventMapの情報を展開して記述しています。つまり、オーバーロド定義を、K extends keyof Tという記述と、T[K]という記述を用いると、Tに定義されている内容を展開して書いているのと同じ意味になります(=定義だけ書いておけば展開してくれる)。便利ですね。

TypedEventTargetの定義の解説

今回作ったTypedEventTargetでも、K extends keyof Tは使っています。定義を順番に見ていきましょう。

イベントの型定義

まずはイベントの型定義からです。

export interface TypedCustomEvent<T extends EventTarget, D>
extends CustomEvent<D>{
  currentTarget: T;
  detail: D;
}

TypedEventTargetで扱うイベントの型は、TypedCustomEventです。これはCustomEventを継承しつつ、currentTarget変数の型も指定できるようにしたものです。型パラメータも2つ、1つはイベントの送信元となる型、もう一つはイベントの付加情報(detail)の型です。前者は、EventTargetを継承しているはずなので、型パラメータにも反映しています。T extends EventTarget>と記述すると、EventTargetを継承した型のみ、Tに渡せるようになります。

イベントリスナ

次にイベントリスナです。JavaScript/TypeScriptのイベントリスナは、null, handleEvent()メソッドを持つオブジェクト、関数のどれかです(参考: EventTarget: addEventListener()メソッド)。これに合わせるため、関数とオブジェクトそれぞれのイベントリスナ(TypedEventListenerTypedEventListenerObject)を定義して、さらにそのどちらでも取れる型(TypedEventListenerOrEventListenerObject)も定義しています。この辺りの名前は、TypeScriptでのイベントリスナの定義(EventListener, EventListenerObject, EventListenerOrEventListenerObject)に合わせてあります。

export interface TypedEventListener<T extends EventTarget, D>{
  (this: T, evt: TypedCustomEvent<T, D>): void;
}
export interface TypedEventListenerObject<
T extends EventTarget, D>{
  handleEvent(evt: TypedCustomEvent<T, D>): void;
}
export type TypedEventListenerOrEventListenerObject<
T extends EventTarget, D> =
  TypedEventListener<T, D> | TypedEventListenerObject<T, D>;

TypedCustomEventcurrentTarget用の型とdetail用の型をパラメータとして受け取るのと同じように、イベントリスナでもそれぞれの型パラメータを定義しています。ちなみに、イベントリスナ関数(TypedEventListener)もインタフェースとして定義してあります。これもTypeScriptでの定義(EventListener)に合わせたものです。

イベントリスナ関数(TypedEventListener)の第一引数は、this: Tです。関数定義でのthisは、この関数が呼ばれた場合のthisの型を指定します(参考: Declaring
this in a Function
。JavaScriptの仕様で、EventTargetがリスナ関数を呼び出す場合、関数のthisに自分自身を設定します(ハンドラー内での "this" の値。この振る舞いを定義上明示するために、this: Tを記述しています。実際はイベントのcurrentTargetが同じものを示すので、無くても構いません。関数内でthisを使うかcurrentTargetを使うかはお好みで。ちなみに、リスナとして関数ではなくオブジェクト(EventListenerObjecthandleEventメソッドを実装したオブジェクト)を指定した場合は、handleEventメソッド内でのthisはそのオブジェクトを示します。

TypedEventTarget定義

最後にTypedEventTargetの定義を見てみましょう。

export class TypedEventTarget<
  T extends TypedEventTarget<T, Events>,
  Events extends Record<string, any>>
  extends EventTarget {
  addEventListener<K extends keyof Events>(
    type: K,
    listener: TypedEventListenerOrEventListenerObject<T, Events[K]> | null,
    options?: AddEventListenerOptions | boolean): void;
  addEventListener(...args: Parameters<EventTarget["addEventListener"]>){
    super.addEventListener(...args);
  }
  removeEventListener<K extends keyof Events>(
    type: K,
    listener: TypedEventListenerOrEventListenerObject<T, Events[K]> | null,
    options?: EventListenerOptions | boolean): void;
  removeEventListener(...args: Parameters<EventTarget["removeEventListener"]>){
    super.removeEventListener(...args);
  }
  dispatchCustomEvent<K extends keyof Events>(
    type: K, detail?: Events[K]): boolean;
  dispatchCustomEvent(type: string, detail: any){
    return super.dispatchEvent(new CustomEvent(type, {detail}));
  }
}

クラス宣言部分

まずはクラス宣言の部分です。

export class TypedEventTarget<
  T extends TypedEventTarget<T, Events>,
  Events extends Record<string, any>>
  extends EventTarget {

TypedEventTargetは、TEventsの2つの型パラメータをとります。Tはこのクラスを継承したクラスです。このクラスを継承しているクラスしか指定できないよう、extendsで制限しています。最初の型パラメータ T extends TypedEventTarget<T, Events>がそれです。定義内にもTypedEventTargetとその型パラメータが出てきて、循環しているように見えますが、これは継承先のクラスをパラメータとして受け取る場合の一般的な書き方で、Javaでもよくやるテクニックです。このクラスを作っている時には、継承先のクラスは存在しないので、まだ存在しないクラスをパラメータとして指定していることになり、タイムパラドクスが起きそうです。型パラメータ自体、未知の型を扱う仕組みなので、存在していなかった型が指定されることも想定の範囲内ではありますが、面白い仕組みですね。

Eventsは、イベントの種類(type)と付加情報(detail)を定義する型です。どんな型でも良いわけではなく、{"key1": detailの型1, "key2": detailの型2, ...}という形を想定しています。こういう型はTypeScriptではレコード型と言われ、Record<string, any>で表せます。Events extends Record<string, any>と、extendsと組み合わせることで、Eventsに指定できる型をRecordと互換性のあるものだけに制限しています。

addEventListenerメソッドの定義

次にメソッド定義を見ていきます。まずはaddEventListener

  addEventListener<K extends keyof Events>(
    type: K,
    listener: TypedEventListenerOrEventListenerObject<T, Events[K]> | null,
    options?: AddEventListenerOptions | boolean): void;
  addEventListener(...args: Parameters<EventTarget["addEventListener"]>){
    super.addEventListener(...args);
  }

前述したHTMLDivElementでのaddEventListenerの定義と同じく、オーバーロード定義と、メソッドの実体の定義の2つがあります。オーバーロード定義の方は、先に解説した、extends keyofを使用しており、最初の引数typeの型は、クラスの型パラメータEvents(Recrod<string, any>の派生)のキー文字列を指定しています。二番目の引数listenerは、イベントを受け取るリスナーのインスタンスで、その型は、先に定義したTypedEventListenerOrEventListenerObjectです。TypedEventListenerOrEventListenerObjectはイベント送信元クラスとイベントの付加情報(detail)の型の2つの型パラメータを取りますが、1つ目にはT、クラス宣言時に出てきた、TypedEventListenerを継承したクラスを指定しています。2つ目にはEvents[K]を指定しています。KEvents内のキーなので、Event[K]は、そのキーに対応するものになります。Eventsでは、イベントを示す文字列と付加情報(detail)の型を定義する想定なので、Event[K]はイベントKに対応する付加情報の定義ということになります。この指定のおかげで、addEventListenerを呼び出す箇所で、付加情報の定義の補完と型チェックが有効になります。

addEventListenerメソッドの実体の定義では、実際に実行される処理を記述します。引数は、実際にはEventTargetクラスのaddEventListenerが持つ引数と同じものを指定しているだけですが、その書き方に特徴があります。まず引数の名前部分ですが、...argsと、名前の前に...が記述されています。これは残余引数と呼ばれるもので、メソッドの残りの引数を全て表します。通常は、(a, ...args)というように、先に引数を指定して残りの全てを指定する使い方が多いですが、この例では引数の全てを取得するため、...argsと記述しています。...argsの型には、Parameters<EventTarget["addEventListener"]>を指定しています。Parametersは、TypeScriptの仕様内ではUtility Typesと呼ばれるものの一つで(参考: Parameters<Type>)、あるメソッドの引数の型を取得します。ここでは、EventTargetクラスのaddEventListenerメソッドの引数の型を取り出しています。結果的に、TypedEventTarget.addEventListenerは、EventTarget.addEventListenerと同じ引数を持つことになります。EventTarget.addEventListenerの定義をコピペして持ってきても同じ意味になりますが、Parametersを使っておけば、EventTarget.addEventListenerの定義が変わった場合でも、記述を変える必要はありません。便利ですね。

addEventListenerメソッドの実装では、親クラスEventTargetaddEventListenerに、全ての引数(...args)を渡しています。ここでの...はスプレッド構文で、配列やオブジェクトの内容をここに展開するという意味です。残余引数でまとめてパラメータを受け取り、スプレッド構文で展開してメソッドに渡しています。ちなみに、オーバーロード定義を行う場合、実体となる関数は使う側からは見えません(privateを書いてないのに!)。そのため、一見EventTarget.addEventListenerに渡せる引数は全て渡せるように、つまりオーバーロード定義で頑張って指定した、Eventsから持ってきたイベントタイプと付加情報の型が台無しに見えますが、実際にはこのメソッド実装は使う側からは見えないので、台無しにはなりません。気が利いてますね。

removeEventListenerメソッドとdispatchCustomEventメソッドの定義

ここまで来れば、以降は特に特別なことはありません。一気に残りの定義を見ていきましょう。

  removeEventListener<K extends keyof Events>(
    type: K,
    listener: TypedEventListenerOrEventListenerObject<T, Events[K]> | null,
    options?: EventListenerOptions | boolean): void;
  removeEventListener(...args: Parameters<EventTarget["removeEventListener"]>){
    super.removeEventListener(...args);
  }
  dispatchCustomEvent<K extends keyof Events>(
    type: K, detail?: Events[K]): boolean;
  dispatchCustomEvent(type: string, detail: any){
    return super.dispatchEvent(new CustomEvent(type, {detail}));
  }

removeEventListenerメソッドも、dispatchCustomEventメソッドも、addEventListenerで使ったのと同じテクニックを使ってオーバーロードを定義しています。少し違うのはdispatchCustomEventメソッドの方で、こちらは受け取った引数(typedetail)を使ってCustomEventを作成し、EventTarget.dispatchEventに渡しています。これにより、EventTargetと同じ仕組みでイベントが発生します。

まとめ

型付きのEventTargetが欲しかったので、TypeScript型システムを利用して自作してみました。TypeScriptの型システムは非常に良くできており、複雑で強力な表現とシンプルな記法を両立させつつ、ベースとなるJavaScriptの言語や実装との整合性も確保されています。JavaScriptのDOM APIにも一通り型が付けられていますが、何故かEventTargetの型が甘く、そのためこれまで、何度となくEventTargetもどきを自作してきました。今回型システムを調べ直し、ようやく満足のいくものを作れました。

今回作ったTypedEventTargetは、筆者作の分散共有アプリ向けミドルウェア: Madoiクライアントライブラリにも同じものが入っています。TypeScriptの他の機能(Decorator)やReactも活用して分散共有アプリのネットワーク関連コードを一切書かなくて済む仕組みを導入していますので、興味のあるかたはどうぞ(このページがわかりやすいかと思います)。

TypedEventTargetについて、もっと良い書き方がある、あるいはおかしな部分があるなどあれば、是非教えてください。

Discussion