LoginSignup
5
2

More than 3 years have passed since last update.

TypeScript の event を良い感じの type safety に実装する

Last updated at Posted at 2020-06-28

TypeScript における代表的な event 実装方法

TypeScript は言語仕様としては event 機能を持っていません。
なので class に event を実装する場合、自前で実装するか EventEmitter のような補助クラスを使うことになりますが、TypeScript ですのでやはり type safety に実装したいところです。
type safety に実装する代表的な方法は次の通りです。

  • EventEmitter + 型定義 (event 一つ一つに特化した on(), once(), off(), removeAllListeners() あたりのメソッドを定義していく)
  • StrictEventEmitter を導入する
  • TypedEvent を用意する

それぞれの特徴は次の通りです。

方法 共通クラス導入方法 event 表現方法 event 実装コスト 表現力
EventEmitter + 型定義 不要1 引数
StrictEventEmitter パッケージ追加 引数
TypedEvent 自前実装 プロパティ 2

この中では StrictEventEmitter が断然お勧めです。今後のデファクトスタンダードになるのかなとも思います。

StrictEventEmitter に対して気になる点

StrictEventEmitter に対して、2点ほど気になる点があります。

  • EventEmitter の継承が必要
  • emit が public アクセス可能

前者はオブジェクト指向設計の観点からあまり適切ではないですね。設計が濁ります。概念として抽象・具象の関係にあるものが継承されるべきです。

後者は前者よりも切実です。event の発火をクラス外部からも行えてしまいます。prefix としてアンダースコアでも付いているならまだしも、他のメソッドと全く同じようにぶらさげているのでは「発火しても構わないよ」と言っているようなものです。全然 safety ではありませんね。
例えば event の発生条件を勘違いしたチームメンバーが「なぜかこのタイミングでは event が通知されないので手動で発生させる」なんてコメントと共に外部から emit() を叩いてしまうかもしれないわけです。

ちなみに後者は、他の2つの方法についても同じく生じる問題です。

EventEmitter + EventPort というアプローチ

ということで、私はこれらのいずれの方法でもなく、下記の EventPort というクラスを自前実装して EventEmitter と組み合わせて使用しています。

events.ts
import { EventEmitter as OriginalEventEmitter } from "events";


export class EventEmitter extends OriginalEventEmitter {
    public emit<T extends (...args: any[]) => void>(port: EventPort<T>, ...args: Parameters<T>): boolean;
    public emit(name: string | symbol, ...args: any): boolean;
    public emit(event: any, ...args: any[]) {
        const name = event instanceof EventPort ? event.name : event;
        return super.emit(name, ...args);
    }
}


/**
 * A port to deliver an event to listeners.
 */
export class EventPort<T extends (...args: any[]) => void> {
    /**
     * Initialize an instance of EventPort<T> class.
     * @param name The name of the event.
     * @param emitter An instance of EventEmitter class.
     */
    public constructor(name: string | symbol, emitter: EventEmitter) {
        this._name = name;
        this._emitter = emitter;
    }

    private readonly _name: string | symbol;
    private readonly _emitter: EventEmitter;

    /**
     * Gets the name of the event.
     */
    public get name() {
        return this._name;
    }

    /**
     * Adds a listener.
     * @param listener The listener to be added.
     */
    public on(listener: T) {
        this._emitter.on(this._name, listener);
    }

    /**
     * Adds a listener that will be called only once.
     * @param listener The listener to be added.
     */
    public once(listener: T) {
        this._emitter.once(this._name, listener);
    }

    /**
     * Removes a listener.
     * @param listener The listener to be removed.
     */
    public off(listener: T) {
        this._emitter.off(this._name, listener);
    }

    /**
     * Removes the all listeners.
     * @param listener
     */
    public removeAllListeners() {
        this._emitter.removeAllListeners(this._name);
    }
}

下記は使用例です。

import { EventEmitter, EventPort } from "./events";

class Hoge {
    public constructor() {
        this._eventEmitter = new EventEmitter();
        this._fugaCalledEvent = new EventPort("fugaCalled", this._eventEmitter);
        this._piyoCalledEvent = new EventPort("piyoCalled", this._eventEmitter);
    }

    private readonly _eventEmitter: EventEmitter;
    private readonly _fugaCalledEvent: EventPort<(value: string) => void>;
    private readonly _piyoCalledEvent: EventPort<(a: number, b: number) => void>;

    public get fugaCalledEvent() {
        return this._fugaCalledEvent;
    }

    public get piyoCalledEvent() {
        return this._piyoCalledEvent;
    }

    public fuga(value: string) {
        this._eventEmitter.emit(this._fugaCalledEvent, value);
    }

    public piyo(a: number, b: number) {
        this._eventEmitter.emit(this._piyoCalledEvent, a, b);
    }
}


const hoge = new Hoge();
hoge.fugaCalledEvent.on(value => console.warn(`value: ${value}`));
hoge.piyoCalledEvent.on((a, b) => console.warn(`a: ${a}, b: ${b}`));

hoge.fuga("Hello.");
// value: Hello

hoge.piyo(1, 2);
// a: 1, b: 2

EventPort クラスは TypedEvent クラスのようにプロパティとして event を表現します。
TypedEvent クラスとの大きな違いは、

  • 外部に本当に公開したい機能だけを持つ
  • event の引数ではなく listener の型を指定する形にすることで表現力の弱さを解消
  • 処理は EventEmitter に委譲
  • event の発火は EventEmitter を通じて本体クラスが行う

の4点です。
また、本体クラスは EventEmitter の継承は行わず、単に集約の関係に留めます。これにより、誤った継承関係の排除だけでなく emit() の隠蔽も達成しています。

ちなみに emit() については、第一引数に EventPort を受け取れるようにし、可変長引数を Conditional Types でフィットさせるようにオーバーロードしています。

ご覧の通り、listener 登録も event 発火も type safety です。
image.png
image.png

まとめ

以上を踏まえて表を更新してみました。

方法 共通クラス導入方法 event 表現方法 event 実装コスト 表現力 設計汚染 emit 隠蔽
EventEmitter + 型定義 不要1 引数 不十分
StrictEventEmitter パッケージ追加 引数 不十分
TypedEvent 自前実装 プロパティ 2 不十分
EventPort 自前実装 プロパティ 完全

右端2列が気にならない方は StrictEventEmitter、気になる方は EventPort が良いと思います。
EventPort は自前実装の形になりますが、非常に単純なつくりですので問題にならないと思います。

あとは event 表現方法ですかね。どっちが優れているというものではなく、好みの問題が大きいかなと思います。
ちなみに私は元々 C#er なので、プロパティ形式の方がどちらかというとしっくりきます。


更新履歴

  • 2020/07/14: events.ts で EventEmitter を拡張する際の二重実行対策 (_emit が存在しない場合にのみ実行) を追加。
  • 2020/08/20: events.ts で EventEmitter を継承+オーバーロードで拡張するよう変更。

  1. Node.js 上で動作させる、或いは WebPack 等でバンドルしてブラウザ上で動作させる場合。それ以外の場合はパッケージ追加が必要。 

  2. event の引数の表現力がやや低く、名前が固定、一つしか持たせられない、などの欠点がある。 

5
2
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
5
2