0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

TypeScriptの型システムと戯れていたらいい感じの型付きイベントライブラリができた

0
Last updated at Posted at 2026-06-14

あらゆる場所に型が付く、快適なイベントライブラリ tcet をアップデートしました。どんな特徴があるかを紹介し、どうやって作ったかを解説します。以下、tcet の npmjs, GitHub リポジトリです。

作ったもの

最強(たぶん)の型付きイベントライブラリ。npm i tcet を実行すると、以下のように使えます。ちなみに、静的ビルドで生成されるJavaScriptコードは195バイトです。

まず、イベントを発生させるクラスをつくります。その際、TypedCustomEventTarget クラスを継承して、自身のクラスとイベント定義({eventType: detailType; ...})を与えます。例えば、'greeting' という名前の、文字列('string')をパラメータとして受け取るイベントであれば、

class MyClass extends TypedCustomEventTarget<
  MyClass,
  {greeting: string}
>{
 ...
}

という定義になります。定義したクラスでは、イベント定義に応じて dispatchEvent メソッドが使えるので、適切なタイミングで呼び出します。イベントを受け取る側は addEventListener を呼び出してリスナーを登録します。型がついていることを除けば、JavaScript の EventTarget とほぼ同じです。

class MyClass extends TypedCustomEventTarget<MyClass, {greeting: string}>{
  fire(){
    // イベントの発行。第一引数は'greeting'のみ。第二引数のdetailはstring型のみ。
    this.dispatchEvent('greeting', {detail: 'hello'});
    // イベントのインスタンスを渡すこともできます。
    this.dispatchEvent(new TypedCustomEvent('greeting', {detail: 'hello'}));
  }
}

const mc = new MyClass();
// リスナの登録
mc.addEventListener('greeting', (e)=>{
  // tcet の型付けにより、e.typeは 'greeting'型、e.currentTarget は MyClass型、
  // e.detailは string型。
  console.log(`${e.detail}`);
});
mc.fire(); // 'hello' が出力される。

// リスナーを独立して定義するときは、`ListenerFor` を使うと便利です。
// 引数eも、構造分解(分割代入)を使うとシンプルに書けます。
const listener: ListenerFor<MyClass, 'greeting'> = ({detail})=>{
  console.log(`${detail}`);
};
mc.addEventListener('greeting', listener);
mc.removeEventListener('greeting', listener);

イベントはマップのように定義します。上記ではgreetingのみ定義していますが、当然複数定義できます。

あらゆるところに型が付き、コード補完が利用でき、型チェックが行われます。

  • dispatchEvent, addEventListener, removeEventListener の引数
  • ListenerFor の2つ目の型パラメータ
  • リスナに渡されるイベントの detail, currentTarget, type 属性
  • など

どのような型チェックが行われるかを詳しく書くと、以下のようになります。

class MyClass extends TypedCustomEventTarget<
  MyClass,
  {greeting: string; foo: void}
>{
  fire(){
    this.dispatchEvent("greeting", {detail: "hello"});  // OK
    this.dispatchEvent("greeting", {detail: "hello", cancelable: true});  // OK
    this.dispatchEvent("greeting");  // NG。{detail: "文字列"}が必要。
    this.dispatchEvent("greeting", {});  // NG。{detail: "文字列"}が必要。
    this.dispatchEvent("foo");  // OK
    this.dispatchEvent("foo", {cancelable: true});  // OK
    this.dispatchEvent("foo", {detail: "hello"});  // NG。fooはdetail無し(void)
    this.dispatchEvent("bar");  // NG。barは定義されていない。
    this.dispatchEvent(new Event("greeting", {detail: "hello"}));  // NG。TypedCustomEventが必要
    this.dispatchEvent(new CustomEvent("greeting", {detail: "hello"}));  // NG。TypedCustomEventが必要
    this.dispatchEvent(new TypedCustomEvent("greeting", {detail: "hello"}));  // OK
    this.dispatchEvent(new TypedCustomEvent("greeting", {detail: "hello", cancelable: true}));  // OK
    this.dispatchEvent(new TypedCustomEvent("greeting"));  // NG。{detail: "文字列"}が必要。
    this.dispatchEvent(new TypedCustomEvent("greeting", {}));  // NG。{detail: "文字列"}が必要。
    this.dispatchEvent(new TypedCustomEvent("foo"));  // OK
    this.dispatchEvent(new TypedCustomEvent("foo", {cancelable: true}));  // OK
    this.dispatchEvent(new TypedCustomEvent("foo", {detail: "hello"}));  // NG。fooはdetail無し(void)
    this.dispatchEvent(new TypedCustomEvent("bar"));  // NG。barは定義されていない。
  }
}
const mc = new MyClass();
const gl: ListenerFor<MyClass, "greeting"> = ({detail})=>{
    console.log(detail);
};
const fl: ListenerFor<MyClass, "foo"> = ()=>{};
mc.addEventListener('greeting', gl);  // OK
mc.addEventListener('foo', gl);  // NG
mc.addEventListener('foo', null);  // OK
mc.addEventListener('bar', fl);  // NG

このチェックを実現するために、JavaScriptの EventTargetCustomEvent を継承しつつ、型情報を関連クラスや定義に渡し、あらゆるフィールドやメソッドに型が付くようにしています。主要なクラスの関係を示すと、以下のようになります。名前に "Typed" がついているのが、作ったクラスです。

どうやって作ったか

TypedCustomEventTargetの宣言部分

型情報を取得して渡していくには、TypeScriptの型に関する機能をうまく使う必要があります。まず、大元の設計として、イベントターゲットの宣言部分でイベントの定義を行っています。

export interface TypedCustomEventTarget<
    T extends TypedCustomEventTarget<T, Events>,
    Events extends Record<string, any>
> extends EventTarget {
    ...
}

型パラメータの1つ目 T はこのクラスを継承したクラス自身で、これはイベントの currentTarget やイベントリスナの this (リスナがfunctionの場合) に持っていきます。ちなみに継承元のクラスに自分自身の型を渡す方法は Curiously Recurring Template Pattern(CRTP)と呼ばれ、C++のテンプレートでも使用される、由緒あるテクニックです。

2つ目の Events はイベント定義で、マップ形式で定義します。Recordはプロパティキーとプロパティ値の型を指定する型で、イベント定義を行う Events のキーが string になるように制約をかけています。

この TypedCustomEventTarget を継承し、イベントソースとなるクラスを定義します。

class MyClass extends TypedCustomEventTarget<MyClass, {
    greeting: string;
    foo: void;
>{
    ...
}

ここで定義された型を渡していくには、まずはそれを取り出す仕組みが必要です。

型の導出

EventsOf<T>

MyClass で定義されたイベントの型情報をイベント関連のメソッドやフィールドに持っていくには、 TypedCustomEventTarget の中で、イベント定義や定義内のキー、値を導出する必要があります。ここが最初の山です。まず、イベント定義自体を取得する型を作ります。それが次の EventsOf です。

export type EventsOf<T extends TypedCustomEventTarget<any, any>> =
    T extends TypedCustomEventTarget<any, infer Events> ? Events : never;

EventsOf はイベントターゲットの型を引数に取り、イベント定義を返します。イベント定義部分を抜き出すために、条件付き型定義と infer キーワードを使用しています。infer で抜き出した方は、条件付き型定義(条件 ? 成立する場合 : 成立しない場合)の後半で利用できます。この場合、T は前半(=より前)の制約から、必ず TypedCustomEventを継承しているので、常に成立し、条件部分で取り出した Events が返されます。

KeyOf<Events>

Events が取り出せたので、Events 内の情報を取り出す型も作っておきます。まずは定義内のキーの集合を取り出す KeyOf です。

type KeyOf<Events> = keyof Events & string;

keyof キーワードは、オブジェクト定義内のキーの和集合(union型)を取り出してくれます。そのうち string だけのものに制約を加えることで、イベントの type として使えるキーの集合が得られます。

EventDetailOf<T, K>

次に、イベントターゲット T から特定のイベントの詳細型を返す型です。

export type EventDetailOf<
    T extends TypedCustomEventTarget<any, any>,
    K extends KeyOf<EventsOf<T>>
    > = EventsOf<T>[K];

EventDetailOf では、イベントターゲット T に加えて、イベントのキー K も受け取ります。先に定義した EventsOf を使って T から Events を取り出し、それにキー K を与えて、対応する詳細型を取り出しています。

型定義

道具が揃ったので、中核となる型を定義していきます。

TypedCustomEvent<T, K>

まずはイベントを型付で扱う TypedCustomEvent です。このクラスは既存のイベントクラスとの互換性を確保するため、CustomEventの派生クラスとして定義しています。

export interface TypedCustomEvent<
    T extends TypedCustomEventTarget<any, any>,
    K extends KeyOf<EventsOf<T>>
    >
    extends CustomEvent<EventDetailOf<T, K>>
{
    readonly type: K;
    readonly currentTarget: T;
}

イベントターゲット T と、このクラスで扱うイベントのキー K を型パラメータとして受け取ります。親クラスの CustomEvent<D> の型パラメータはイベントの詳細(detailフィールド)の型を取るので、TK から抽出して渡しています。定義部では、イベント名 type の型が KcurrentTarget の型が T であることを宣言しています。

この定義自体はTypeScriptの世界の中で型を扱うだけのためのものです。このままでも良いですが、親クラスの CustomEvent<D> は、以下のように、インスタンスを使用して dispatchEvent に渡す使い方を想定しています。

source.dispatchEvent(new CustomEvent('greeting', {detail: 'hello'});

可能な限り互換性を重視し、同じような使い勝手を実現したいので、TypedCustomEvent も new して dispatchEvent に渡せるようにしたいところです。しかし、classを作成すると、トランスパイル時に生成されるJavaScriptコードも増えてしまいます。そこで、TypeScript内での型定義は TypedCustomEvent のまま、JavaScript内での実装としては CustomEvent を流用します。そのため、class の代わりに、コンストラクタ用のインタフェースを作成して、CustomEvent をそれに読み替えるというテクニックを使います。

interface TypedCustomEventInit<D> extends CustomEventInit<D> {
    detail: D;
}
type EventInitDictArg<D> = D extends void ?
    [eventInitDict?: EventInit] :
    [eventInitDict: CustomEventInit<D>]; 
interface TypedCustomEventConstructor {
    new<
        T extends TypedCustomEventTarget<T, EventsOf<T>>,
        K extends KeyOf<EventsOf<T>>
    >(
        type: K,
        ...eventInitDict: EventInitDictArg<EventDetailOf<T, K>>
    ): TypedCustomEvent<T, K>;
}
export const TypedCustomEvent =
    CustomEvent as unknown as TypedCustomEventConstructor;

CustomEvent のコンストラクタは2つの引数を持ちます。最初はイベントのキーを表す type で、2番目はイベントのオプション(bubbles, cancelable, composed)や詳細(detail)を持つ eventInitDict です。これは detail を含むので、定義に応じて省略の可否が決まります。詳細がvoidの場合はなくても構いませんし、通常の型の場合は必須になります。また、voidの場合に指定する場合、detailは拒否する必要があります。

これを表現するために、まずイベント作成時に受け付けるオプションを表す TypedCustomEventInit<D> を作成します。CustomEvent<D>D が void ではない場合も detail を省略できてしまうので、省略できないようにしています。省略しても良い detail かどうかは、イベント定義側で表現できます。例えば、{greeting?: string} とすれば省略可能になります。次にイベントのコンストラクタの第二引数を表現するため、EventInitDictArg<D> を定義します。条件により定義が変わるので、条件付き型定義を行います。詳細型を表すテンプレート引数 Dvoid であれば、引数配列の要素は0か1、1の場合は detail を持たない EventInit にします。void ではない場合は、detail を持つ CustomEventInit<D> を 1 つ持つ配列にしています。第二引数を可変長引数(...args)にし、この型を使ってコンストラクタを持つインタフェース TypedCustomEventConstructor を宣言します。

これでコンストラクタが型として表現できました。実装は CustomEvent のものを使いたいので、定数 TypedCustomEvent を宣言し、それに CustomEvent を代入します。この際、無理やり先ほど定義した TypedCustomEventConstructor 型にキャストします。これで、実態は CustomEvent ですが、コンストラクタが適切に型付けされているものが出来上がりました。

ちなみに JavaScript に変換される際には、CustomEvent が代入された定数と、その定数の export 宣言の2行が生成されます。以下抜粋。

const s = CustomEvent;
export {
  s as TypedCustomEvent,
};

TypedCustomEventListener<T, K>

次にイベントリスナーです。イベントリスナーには関数とオブジェクトの2種類がありますが、ここでは関数のもののみ示します。

export interface TypedCustomEventListener<
    T extends TypedCustomEventTarget<any, any>,
    K extends KeyOf<EventsOf<T>>>
{
    (this: T, evt: TypedCustomEvent<T, K>): void | Promise<void>;
}

thisのバインド以外は変わったところはありません。今までに定義した型を使用して、リスナーに渡されるイベントの型に TypedCustomEvent を指定しているだけです。

第一引数の定義 this: T は、リスナーの this がイベントターゲットに型になることを示しています。これは JavaScriptの EventTarget の仕様により、イベントリスナーを呼び出される際に、this が、そのイベントを発生させた EventTarget に設定されるので、それを明示するために記述しています。リスナーをアロー関数で定義した場合は関係ありませんが、function で定義した場合は thisT 型になります。

const l: TypedCustomEventListener<MyClass, "greeting"> = ({detail})=>{
    console.log(this); // thisは外側で決定される。
};
const l: TypedCustomEventListener<MyClass, "greeting"> = function({detail}){
    console.log(this); // thisはMyClass型
};

ListenerFor<T, K>

イベント周りの型が取得できるようになってので、それらを使ってリスナーの型を定義しておきます。この型は実際にはリスナーの型 TypedCustomEventListenerOrListenerObject にそのまま置き換わるだけです。TypedCustomEventListenerOrListenerObject は名前が長いので、短縮系を用意する目的で定義しています。

export type ListenerFor<
    T extends TypedCustomEventTarget<any, any>,
    K extends KeyOf<EventsOf<T>>
    > = TypedCustomEventListenerOrEventListenerObject<T, K>;

ちなみに JavaScript のイベントにおけるリスナーは、関数とオブジェクトの2種類が存在します。以下、TypeScript での型定義です。

interface EventListener {
    (evt: Event): void;
}
interface EventListenerObject {
    handleEvent(object: Event): void;
}
type EventListenerOrEventListenerObject = EventListener | EventListenerObject;

tcet でもこれに倣い、関数のリスナーとオブジェクトのリスナーの2種類を定義していて、そのunion型が TypedCustomEventListenerOrEventListenerObject です。ListenerFor はそのエイリアスとして振る舞い、TypedCustomEventListener も含まれているので、前述した this のバインドも機能します。

const l: ListenerFor<MyClass, "greeting"> = ({detail})=>{
    console.log(this); // thisは外側で決定される。
};
const l: ListenerFor<MyClass, "greeting"> = function({detail}){
    console.log(this); // thisはMyClass型
};

TypedCustomEventTarget<T, Events>

長い準備を経て、ようやく TypedCustomEventTarget まで辿り着きました。
TypedCustomEventTarget は tcet の中核となるクラスで、イベントを扱うにはまずこのクラスを継承して、イベントを発生させるクラスを作成します。定義に関しては冒頭で説明したので、実装を見ていきましょう。まずはインタフェース定義で、addEventListenerremoveEventListener メソッドを定義しています。

export interface TypedCustomEventTarget<
    T extends TypedCustomEventTarget<T, Events>,
    Events extends Record<string, any>
> extends EventTarget {
    addEventListener<K extends KeyOf<Events>>(
        type: K, listener: ListenerFor<T, K> | null,
        options?: AddEventListenerOptions | boolean): void;
    removeEventListener<K extends KeyOf<Events>>(
        type: K, listener: ListenerFor<T, K> | null,
        options?: EventListenerOptions | boolean): void;
}

どちらのメソッドも、イベントのキーを最初の引数にとり、リスナをその次に、最後にオプションをとります。引数の個数も意味も EventTarget と同じなので、インタフェース定義のみで型をつけることができ、実装も EventTarget のものが使用されます。

次に dispatchEvent メソッドですが、これは EventTarget のものとは異なる処理を行いたいため、class内に定義します。EventTargetdispatchEvent は引数に Event を取りますが、addEventListenerremoveEventListener に合わせて、最初の引数にイベントのキーをとるメソッドも用意します。

export class TypedCustomEventTarget<
    T extends TypedCustomEventTarget<T, Events>,
    Events extends Record<string, any>
> extends EventTarget {
    declare readonly __eventsType: Events;
    dispatchEvent<K extends KeyOf<Events>>(
        event: TypedCustomEvent<T, K>
    ): boolean;
    dispatchEvent<K extends KeyOf<Events>>(
        type: K, ...eventInitDict: EventInitDictArg<Events[K]>
    ): boolean;
    dispatchEvent(
        typeOrEvent: string | Event, eventInitDict?: CustomEventInit
    ): boolean {
        return super.dispatchEvent(
            typeOrEvent instanceof Event ?
                typeOrEvent :
                new CustomEvent(typeOrEvent, eventInitDict)
        );
    }
}

dispatchEvent メソッドは、オーバーロードが2つと、1つの実装が定義されています。オーバーロードは、EventTarget のようにイベント自体(TypedCustomEvent)を引数にとるものと、イベントのキーとオプションを引数にとるものです。後者の引数は、TypedCustomEvent のコンストラクタと同じなので、定義を流用しています。

実装では、2つのオーバーロードを意識する必要があります。第一引数は、イベントそのものが渡される場合と、イベントのキーが渡される場合があります。それを instanceOf で判別して、イベントの場合はそのまま super.dispatchEvent に渡し、そうでない場合は CustomEvent を作成して渡しています。

あともう一行。クラス定義冒頭で、以下の行が記述されています。

    declare readonly __eventsType: Events;

これは、クラス定義内で、Events を使用するためのものです。declare を使用しているので、実際にはビルド時に JavaScript には出力されませんが、TypeScript の型システム内で、Events が使用されている状態にする効果があります。これは、継承時にテンプレート引数に渡した型は、クラス定義内で実際に使用されていないと、消されてしまうためです。これがないと、EventsOf でイベント定義を取り出しても、より抽象的な {[x: string]: any} が取り出されてしまい、型チェックが機能しなくなります。

おわりに

型付きイベントライブラリ tcet での型チェックとその実現方法について解説してきました。TypeScript のイベント関連の定義は最小限のもので、イベントの内容を定義してそれに基づいた型チェックを行うものではありません。しかし実際にイベントを扱うコードを記述する際には、イベントを発生させる側からそれを利用する側に適切に定義情報を伝え、かつコンパイラによるチェックが効くようにしないと、ミスによる情報の不整合によりバグが発生したり、調査検証により時間が失われてしまいます。tcet では、イベントを使ったコードが効率良く書けるよう、可能な箇所全てに型情報が付与されるように設計されています。同時に、既存の EventTarget を最大限活用して同様の使い勝手を実現し、静的ビルドで生成される JavaScript コードも少なくしています。

そもそもの発端は1年ほど前、イベントを扱うアプリケーションを書いていて、同種のライブラリになかなか良いものがないので、自作して公開しました。以下、当時の記事。

当時は欲しかったライブラリが作れたと満足して手を止めたのですが、その後納得のいかない部分が複数出てきたので、今回その全てに手を入れて、最強(おそらく)の型付きイベントライブラリを作りました。

EventTarget を使ってオリジナルのイベントを定義する設計は、React と相性がよくなかったり、型情報が少ないせいで使い勝手が良くなかったりと、あまり行われていないイメージがありますが、ある程度の規模のアプリケーションを開発しようとすると、必ずやりたくなる設計の一つでもあります。tcet の強力な型付けは、極めて快適なイベント設計・利用を実現します。ASL2.0で公開し npmjs にも登録しているので、是非ご利用ください。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?