Help us understand the problem. What is going on with this article?

TypeScript の機能で EventEmitter のイベントの payload を型安全に扱う

More than 3 years have passed since last update.

アイデアとしてはとてもシンプルなので、 TypeScript を使っている人たちはとっくにこんなことやっていそうなんですが、ぱっとググるなどした感じこの件に関する tips が見当たらなかったので、ここに残しておきます。

「ここに載ってるよ!」みたいな情報をご存知の方いらっしゃったら教えてください。

TL;DR

タイトルに書いたことを可能にするコードを、ライブラリとして下記のリポジトリに置いてあります。

README に書いていますが、以下のように使えます。

  • Define your EventEmitter.
/// <reference path="path/to/node_modules/ts-eventemitter/dist/ts-eventemitter.d.ts" />
import {TsEventEmitter, EventBase, Event0, Event1, Event2} from 'ts-eventemitter';

export interface MyEventEmitter extends TsEventEmitter {
    event(name: 'foo'): Event0<MyEventEmitter>;
    event(name: 'bar'): Event1<MyEventEmitter, string>;
    event(name: 'baz'): Event2<MyEventEmitter, number, string>;
    event(name: string): EventBase<MyEventEmitter>;
}

var MyEventEmitter: MyEventEmitter = TsEventEmitter.create();

export default MyEventEmitter;
  • Use it.
/// <reference path="path/to/node_modules/ts-eventemitter/dist/ts-eventemitter.d.ts" />
import {TsEventEmitter, Event0, Event1, Event2} from 'ts-eventemitter';
import MyEventEmitter from 'MyEventEmitter';

MyEventEmitter.event('foo').on(() => {
    console.log('foo');
}).event('bar').on((name: string) => {
    console.log('Hello, ' + name);
}).event('baz').on((id: number, name: string) => {
    console.log('Hello, ' + name '. Your id is ' + id);
});

MyEventEmitter.event('foo').emit();
MyEventEmitter.event('bar').emit('kimamula');
MyEventEmitter.event('baz').emit(1, 'kimamula');

// The below codes raise compilation errors
MyEventEmitter.event('fo').emit(); // typo
MyEventEmitter.event('bar').on((id: number) => {}); // wrong argument type
MyEventEmitter.event('baz').emit(1); // wrong number of argument

EventEmitter とは

Node.js の標準ライブラリに含まれている、イベント駆動のプログラムを書くための API です。

たとえば、クライアントサイドの実装がある程度リッチになってくると、たくさんの JS のオブジェクトが生成されるようになります。

それらのオブジェクトがコミュニケートするのに、参照を保持して直接メソッドを呼び出す、というようなことをしていると、参照関係がごちゃごちゃになってあっという間にスパゲッティになってしまいます。

EventEmitter を使えば、あるイベントにリスナーを登録しておくことで、別のオブジェクトがそのイベントを発行したときに、任意のイベントを呼び出すことができます。
それぞれのオブジェクトは互いに参照を保持せず、どのオブジェクトがそのイベントを発行しているのか、あるいは、どのオブジェクトがそのイベントを listen しているのかを気にする必要がないので、コードをシンプルに保つことができます。

var ev = new EventEmitter;

// listener 登録
ev.on('data', function(data) {console.log('on', data);});

// イベント発行
ev.emit('data', 1);

EventEmitter の payload の型は ...args: any[]

TypeScript のコードの中で EventEmitter を使うと、 payload の型が ...args: any[] であることに切なさを感じることになります。

// DefinitelyTyped の node.d.ts より抜粋
declare module "events" {
    export class EventEmitter implements NodeJS.EventEmitter {
        static listenerCount(emitter: EventEmitter, event: string): number;

        addListener(event: string, listener: Function): EventEmitter;
        on(event: string, listener: Function): EventEmitter;
        once(event: string, listener: Function): EventEmitter;
        removeListener(event: string, listener: Function): EventEmitter;
        removeAllListeners(event?: string): EventEmitter;
        setMaxListeners(n: number): void;
        listeners(event: string): Function[];
        emit(event: string, ...args: any[]): boolean;
   }
}

TypeScript を使っている以上、「このイベント名の場合はこの型」というのをはっきり定義したくなるものです。

TypeScript の "Overload on constants" という機能を使って、この問題を解決することができます。

"Overload on constants" とは

TypeScript 0.9 からある機能です。

たとえば、 Document.createElement(tagName: string): HTMLElement は、 tagName の値によって返す値の具体的な型がことなるわけですが、それを次のように表現できます。

interface Document {
    createElement(tagName: 'canvas'): HTMLCanvasElement;
    createElement(tagName: 'div'): HTMLDivElement;
    createElement(tagName: 'span'): HTMLSpanElement;
    // + 100 more
    createElement(tagName: string): HTMLElement; // この行は最後に持ってこなければならない
}

これを EventEmitter に応用するなら、イベント名によって扱う型を変えてしまえばいい、ということになります。

ただし、 "Overload on constants" は、第一引数の値によって第二引数の型を変える、というようなことはできません。

→ 現在はできるようです。。ただ、 emiton で型を合わせるというようなことはできないので、その点ではここで紹介している方法に優位性はあるかなと。

interface EventEmitter {
    // このような定義はできない
    emit(eventName: 'foo', arg: string): boolean;
    emit(eventName: 'bar', arg: number): boolean;
    emit(eventName: string, arg: number): boolean;
}

したがって、最初にイベント名だけ受け取って型を確定させ、次に payload を受け取るような interface にしてあげる必要があります。
他にもちょっとした小技が必要ですが、簡略化すると下のような感じです。

interface TsEventEmitter {
    event(eventName: 'foo'): TsEvent<string>;
    event(eventName: 'bar'): TsEvent<number>;
    event(eventName: string): void;
}

interface TsEvent<T> {
    emit(payload: T): boolean;
    on(listener: (payload: T) => void): void;
}

class TsEventEmitterImpl implements TsEventEmitter {
    private eventEmitter = new EventEmitter();
    event(eventName: String): any {
        return new TsEventImpl(this.eventEmitter, eventName);
    }
}

class TsEventImpl {
    constructor(private eventEmitter: EventEmitter, private eventName: string) {}
    emit(payload: any): boolean {
        return this.eventEmitter.emit(this.eventName, payload);
    }
    on(listener: (payload: any) => void): void {
        this.eventEmitter.on(this.eventName, listener);
    }
}

このあたりの実装をライブラリとしてまとめたのが、冒頭に紹介した ts-eventemitter です。

Flux で試してみる

React/Flux の本家の TodoMVC のサンプルでは、 Dispatcher から投げられたイベントを Store で以下のようにハンドリングしています。

// Register callback to handle all updates
AppDispatcher.register(function(action) {
  var text;

  switch(action.actionType) {
    case TodoConstants.TODO_CREATE:
      text = action.text.trim();
      if (text !== '') {
        create(text);
        TodoStore.emitChange();
      }
      break;

    case TodoConstants.TODO_TOGGLE_COMPLETE_ALL:
      if (TodoStore.areAllComplete()) {
        updateAll({complete: false});
      } else {
        updateAll({complete: true});
      }
      TodoStore.emitChange();
      break;

    case TodoConstants.TODO_UNDO_COMPLETE:
      update(action.id, {complete: false});
      TodoStore.emitChange();
      break;

    case TodoConstants.TODO_COMPLETE:
      update(action.id, {complete: true});
      TodoStore.emitChange();
      break;

    case TodoConstants.TODO_UPDATE_TEXT:
      text = action.text.trim();
      if (text !== '') {
        update(action.id, {text: text});
        TodoStore.emitChange();
      }
      break;

    case TodoConstants.TODO_DESTROY:
      destroy(action.id);
      TodoStore.emitChange();
      break;

    case TodoConstants.TODO_DESTROY_COMPLETED:
      destroyCompleted();
      TodoStore.emitChange();
      break;

    default:
      // no op
  }
});

action.actionType で switch するのは、ちょっと辛い感じがあります。
ただ、最近 (?) 導入された以下の機能を使うと、もっとすっきり書けるのかもしれません。

上記の実装を、 ts-eventemitter で書き直すと以下のようになります。

// Register callback to handle all updates
AppDispatcher.event('create').on((text: string) => {
    text = text.trim();
    if (text !== '') {
        create(text);
        emitChange();
    }
}).event('toggleCompleteAll').on(() => {
    if (areAllComplete()) {
        updateAll({ 'complete': false });
    } else {
        updateAll({ 'complete': true });
    }
    emitChange();
}).event('undoComplete').on((id: string) => {
    update(id, { 'complete': false });
    emitChange();
}).event('complete').on((id: string) => {
    update(id, { 'complete': true });
    emitChange();
}).event('updateText').on((id: string, text: string) => {
    text = text.trim();
    if (text !== '') {
        update(id, { 'text': text });
        emitChange();
    }
}).event('destroy').on((id: string) => {
    destroy(id);
    emitChange();
}).event('destroyCompleted').on(() => {
    destroyCompleted();
    emitChange();
});

on に指定する引数の型を間違えると、コンパイラがエラーを出してくれるので、安心です。

なお、 以下のリポジトリで、このライブラリを使用した場合の Flux の全体の実装を確認できます。

最後に

node の EventEmitter に限らず、文字列の値によって型を変えたい場合に共通して使えるパターンだと思うので、色々な用途に使っていければいいと思います。

kimamula
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした