追記 (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 名と値の型の整合性を、コンパイラがチェックしてくれるというわけです。
EventEmitter
を keyof
を使って定義する
個人的には、 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.watch
は EventEmitter
を返します が、これが何という 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