60
46

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

TypeScript 2.1 で導入される `keyof` を使って `EventEmitter` を定義してみる

Last updated at Posted at 2016-11-05

追記 (2018/09/05)

現在では、 TypeScript 2.8 で導入された Conditional Types を利用したよりよい方法があり、これを使うと最後に書いた「この方法の制限」も克服できます。

上の記事の内容はかなり難易度が高いですが、これを理解しておくと Conditional Types への理解がかなり深まるので、おすすめです。(途中で僕のことにも言及してくれていて、ちょっと嬉しかったです)

追記終わり。

keyof の概要

issue: https://github.com/Microsoft/TypeScript/issues/1295
PR: https://github.com/Microsoft/TypeScript/pull/11929

PR の説明文を省略して簡単に示すと、以下のような機能。

interface Thing {
    name: string;
    width: number;
    height: number;
    inStock: boolean;
}

type K1 = keyof Thing;  // "name" | "width" | "height" | "inStock"

type P1 = Thing["name"];  // string
type P2 = Thing["width" | "height"];  // number
type P3 = Thing["name" | "inStock"];  // string | boolean

issue の milestone は TypeScript 2.1.2 になっていますが、 roadmap では 2.1 の追加機能の一覧に含まれていて、 npm i typescript@next で落としてきた 2.1.0-dev.20161104 にすでに含まれていたので、 2.1.0 ですぐ使えるのではないかと思います。
https://github.com/Microsoft/TypeScript/wiki/Roadmap#21-november-2016

というか 2.1 は今月リリースなのか...!

keyof の何が嬉しいのか

元になった issue では、 例として、 Backbone や Immutable.js や _.pluck (lodash では今は _.map) での利用が挙げられています。
例えば Immutable.js の Map は、この機能を使うと以下のように定義できます。

declare module ImmutableJS {
  fucntion Map<T>(obj: T): Map<T>;
  interface Map<T> {
    get<K extends keyof T>(prop: K): T[K];
    set<K extends keyof T>(prop: K, value: T[K]): Map<T>;
  }
}

const map = Immutable.Map({ name: 'François', age: 20 });
map = map.set('age', 21);
map.get('age'); // 21

map.set('name', 21); // error!

property 名と値の型の整合性を、コンパイラがチェックしてくれるというわけです。

EventEmitterkeyof を使って定義する

個人的には、 EventEmitter をこの機能と組み合わせて使うのがすごく胸熱というか、それを楽しみにこの機能の追加を首を長くして待っていたので、ここで説明しようと思います。

EventEmitter と型定義

まず、現在の DefinitelyTyped types-2.0 branch の EventEmitter の型定義を見てみましょう。

export class EventEmitter {
    addListener(event: string | symbol, listener: Function): this;
    on(event: string | symbol, listener: Function): this;
    once(event: string | symbol, listener: Function): this;
    removeListener(event: string | symbol, listener: Function): this;
    removeAllListeners(event?: string | symbol): this;
    setMaxListeners(n: number): this;
    getMaxListeners(): number;
    listeners(event: string | symbol): Function[];
    emit(event: string | symbol, ...args: any[]): boolean;
    listenerCount(type: string | symbol): number;
    // Added in Node 6...
    prependListener(event: string | symbol, listener: Function): this;
    prependOnceListener(event: string | symbol, listener: Function): this;
    eventNames(): (string | symbol)[];
}

例えば on に注目すると、第一引数は string | symbol なら何でもよく、第二引数は Function なら何でもよくなっています。
とにかくゆるゆるです。
EventEmitter を使うときは、特定の名前の event を、特定の型の引数で発火するのが普通だと思いますが、発火する event 名や、それぞれの event に対応する引数の型を宣言する余地はこの型定義にはありません。

例えば、 gulp.watchEventEmitter を返します が、これが何という event を発火するのか、それぞれの event がどのような型の引数を渡してくるのか、型情報から読み取ることはできません。
当然、 event listener を設定する際に、 event の名前を間違えたり、 引数の型を間違えたりしても、コンパイラーが怒ってくれることもないわけです。
人が TypeScript を使うのは、まさにそうしたミスを防いでくれることを期待しているからなのにもかかわらず、外向きの interface として頻繁に利用される EventEmitter がこの有様であることは、これまで多くの TSer の頭を悩ませてきました(たぶん)。

従来の解決策

この問題を解決するために、これまでは string literal types を使った overloading が採用されることがありました。
例えば、 fs.Writable は以下のように定義されています。

export class Writable extends events.EventEmitter implements NodeJS.WritableStream {
    // event 定義以外は関係ないので省略
    
    /**
     * Event emitter
     * The defined events on documents including:
     *   1. close
     *   2. drain
     *   3. error
     *   4. finish
     *   5. pipe
     *   6. unpipe
     **/
    addListener(event: string, listener: Function): this;
    addListener(event: "close", listener: () => void): this;
    addListener(event: "drain", listener: () => void): this;
    addListener(event: "error", listener: (err: Error) => void): this;
    addListener(event: "finish", listener: () => void): this;
    addListener(event: "pipe", listener: (src: Readable) => void): this;
    addListener(event: "unpipe", listener: (src: Readable) => void): this;

    emit(event: string, ...args: any[]): boolean;
    emit(event: "close"): boolean;
    emit(event: "drain", chunk: Buffer | string): boolean;
    emit(event: "error", err: Error): boolean;
    emit(event: "finish"): boolean;
    emit(event: "pipe", src: Readable): boolean;
    emit(event: "unpipe", src: Readable): boolean;

    on(event: string, listener: Function): this;
    on(event: "close", listener: () => void): this;
    on(event: "drain", listener: () => void): this;
    on(event: "error", listener: (err: Error) => void): this;
    on(event: "finish", listener: () => void): this;
    on(event: "pipe", listener: (src: Readable) => void): this;
    on(event: "unpipe", listener: (src: Readable) => void): this;

    once(event: string, listener: Function): this;
    once(event: "close", listener: () => void): this;
    once(event: "drain", listener: () => void): this;
    once(event: "error", listener: (err: Error) => void): this;
    once(event: "finish", listener: () => void): this;
    once(event: "pipe", listener: (src: Readable) => void): this;
    once(event: "unpipe", listener: (src: Readable) => void): this;

    prependListener(event: string, listener: Function): this;
    prependListener(event: "close", listener: () => void): this;
    prependListener(event: "drain", listener: () => void): this;
    prependListener(event: "error", listener: (err: Error) => void): this;
    prependListener(event: "finish", listener: () => void): this;
    prependListener(event: "pipe", listener: (src: Readable) => void): this;
    prependListener(event: "unpipe", listener: (src: Readable) => void): this;

    prependOnceListener(event: string, listener: Function): this;
    prependOnceListener(event: "close", listener: () => void): this;
    prependOnceListener(event: "drain", listener: () => void): this;
    prependOnceListener(event: "error", listener: (err: Error) => void): this;
    prependOnceListener(event: "finish", listener: () => void): this;
    prependOnceListener(event: "pipe", listener: (src: Readable) => void): this;
    prependOnceListener(event: "unpipe", listener: (src: Readable) => void): this;

    removeListener(event: string, listener: Function): this;
    removeListener(event: "close", listener: () => void): this;
    removeListener(event: "drain", listener: () => void): this;
    removeListener(event: "error", listener: (err: Error) => void): this;
    removeListener(event: "finish", listener: () => void): this;
    removeListener(event: "pipe", listener: (src: Readable) => void): this;
    removeListener(event: "unpipe", listener: (src: Readable) => void): this;
}

event として、 close, drain, error, finish, pipe, unpipe があることと、それぞれに決まった型の引数が紐づけられていることが分かります。
この方法の最大の欠点は、見て分かる通り、 DRY でないことです。
event 名と引数の型の紐づけはどのメソッドでも同じはずなのに、メソッドごとに繰り返し定義する必要がある上、仮にどこかのメソッドだけ間違えて定義してしまっても、コンパイラーは怒ってくれません。

重複定義しなくて済むようにするため、以前以下のような解決策を提示しましたが、これも型定義の問題を解決するためだけに EventEmitter を一枚 wrap しなければいけないという点で、あまり好ましいものではありません(今でも同様の方法を使っていますが)。
http://qiita.com/kimamula/items/82704786af4ef49630fe

keyof を使った解決策

以下のように定義してしまいましょう。

export class EventEmitter<T> {
    addListener<K extends keyof T>(event: K, listener: (arg: T[K]) => any): this;
    on<K extends keyof T>(event: K, listener: (arg: T[K]) => any): this;
    once<K extends keyof T>(event: K, listener: (arg: T[K]) => any): this;
    removeListener<K extends keyof T>(event: K, listener: (arg: T[K]) => any): this;
    removeAllListeners<K extends keyof T>(event?: K): this;
    setMaxListeners(n: number): this;
    getMaxListeners(): number;
    listeners<K extends keyof T>(event: K): ((arg: T[K]) => any)[];
    emit<K extends keyof T>(event: K, arg: T[K]): boolean;
    listenerCount<K extends keyof T>(type: K): number;
    // Added in Node 6...
    prependListener<K extends keyof T>(event: K, listener: (arg: T[K]) => any): this;
    prependOnceListener<K extends keyof T>(event: K, listener: (arg: T[K]) => any): this;
    eventNames(): (string | symbol)[];
}

/**
 * event 名と引数の紐づけを interface で定義する
 */
export interface WritableEvents {
    close: void;
    drain: void;
    error: Error;
    finish: void;
    pipe: Readable;
    unpipe: Readable;
}

export class Writable extends events.EventEmitter<WritableEvents> implements NodeJS.WritableStream {
    // event 定義以外は関係ないので省略
    
    // event 定義は extends events.EventEmitter<WritableEvent> だけで OK!
}

Excellent!
DRY ですし、 event と引数の対応が一目で分かって可読性も高いです。

既存の定義との共存

EventEmitter に型パラメータが必須になってしまうと、けっこうな breaking change なので、現実的には以下のような定義に落ち着くのではないかと思います。

export interface EventEmitter {
    // 従来の EventEmitter 定義
}
export interface TypedEventEmitter<T> {
    // keyof を使った EventEmitter 定義
}

interface EventEmitterConstructor {
	new (): EventEmitter;
	new <T>(): TypedEventEmitter<T>;
}

declare var EventEmitter: EventEmitterConstructor;

// EventEmitter は型パラメータありでもなしでも作れる
const normalEventEmitter = new EventEmitter();
const typedEventEmitter = new EventEmitter<{ foo: string; bar: number; }>():

この方法の制限

2.1 がリリースされたらすぐにでも上記の変更を DefinitelyTyped に PR してやろうかと考えていたのですが、この投稿を書きながら、この方法のひろくは許容されなさそうな制限に気づきました。
EventEmitter の event の引数は本来可変長ですが、この方法では、 event の引数は必ず 1 つでなければならないことです。
引数なしの event は、上記の例の close のように、引数を void で宣言することで一応定義できますが、その場合でも emit する際に void な引数を明示的に渡さなければならないのが切ないところです。

writable.emit('close'); // error
writable.emit('close', void 0); // ok

https://github.com/Microsoft/TypeScript/issues/5453 で提唱されている機能が導入されれば、このあたりうまいこと行きそうな気がするので、期待したいところです。

終わりに

上記のような制限はあるものの、通常の project であれば十分実用に耐えうるテクニックではないでしょうか。
個人的に、 EventEmitter の event に複数の引数を渡すのは可読性の点からあまり好ましくないと思っていて、そういうときは引数をオブジェクトにしてしまいますし、引数がないときに void 0 みたいなのを書かなければならないのも、コストとして許容範囲内だと思います。

執筆時点の typescript@next で試し書きしたものを github に上げたので、よろしければご覧ください。
https://github.com/kimamula/TypeScript-definition-of-EventEmitter-with-keyof

60
46
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
60
46

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?