TypeScriptの型定義はアプリの初期設計時に適切な形で導入すれば後の開発が楽になりますが、使うだけならともかく自分で定義するほどには理解が及ばないというケースは多いかと思います。
特に Index types や Index types は扱えると便利なのですが、聞かれると説明に窮することがあり、自分の理解を深めるためにもまとめました。
※以下の記事は TypeScript3.1.1 が前提です。
EventEmitterを型安全に扱う
ありがちですが、 EventEmitter を題材にします。
ほぼこちらの記事の二番煎じです。
TypeScript 2.1 で導入される keyof を使って EventEmitter を定義してみる
以下のように interface
を定義します。
export type EventDef = {[P in string | symbol]: any[]};
export interface ITypedEmitter<T extends EventDef> {
addListener<P extends keyof T>(event: P, listener: (...args: T[P]) => void): this;
emit<P extends keyof T, K extends T[P]>(event: P, ...args: K): boolean;
eventNames<P extends keyof T>(): P[];
listenerCount<P extends keyof T>(type: P): number;
listeners<P extends keyof T>(event: P): Array<(...args: T[P]) => void>;
off<P extends keyof T>(event: P, listener: (...args: T[P]) => void): this;
on<P extends keyof T>(event: P, listener: (...args: T[P]) => void): this;
once<P extends keyof T>(event: P, listener: (...args: T[P]) => void): this;
removeAllListeners<P extends keyof T>(event?: P): this;
removeListener<P extends keyof T>(event: P, listener: (...args: T[P]) => void): this;
}
この例ではNodeJSのEventEmitterとWeb用のEventEmitter3共通の関数を全て定義してみましたが、実業務の場合は、使う関数だけを定義すれば良いと思います。
使い方は、
const emitter1: ITypedEmitter<{
hoge: [string, number],
fuga: [number, number?],
piyo: []
}> = new EventEmitter() as any;
emitter1.on("unknown", () => { console.log("hogehoge") }); // エラー
emitter1.on("hoge", (val1, val2) => { console.log("hogehoge") }); // ok
emitter1.emit("hoge", "hogehoge", 1); // ok
emitter1.emit("hoge", "hogehoge", "1"); // エラー
emitter1.emit("fuga", 1, 2); // ok
emitter1.emit("fuga", 1); // ok・・・2つ目の引数はオプショナル
emitter1.emit("fuga", 1, 2, 3); // エラー
emitter1.emit("piyo", ); // ok
これで開発末期にスペルミスに気づいても安心です。
as any
が気になる方も居られるかもしれませんが、元々型の無いJSライブラリを後付けで型付けする場合などは容赦無く使っていいかと思ってます。
tupleを使った引数指定
まず、イベント名と引数を定義する型を宣言しています。
export type EventDef = {[P in string | symbol]: any[]};
キーがイベント、値が引数の配列となります。
以下はその定義例です。
interface ISample extends EventDef {
hoge: [string, number],
fuga: [number, number?],
piyo: []
}
引数の配列を[string, number]
ように tuple で記述することで、第一、第二引数それぞれの型を定義することが出来ます。
また tuple の定義では[number, number?]
のようにオプショナル指定も可能で、上記の場合fugaの第二引数が省略可能となります。
Index typesを用いた各関数へのイベントと引数の紐付け
下記のemit
関数のように Index types を用いて EventDefで定義したイベント名と引数を関数に紐づけています。
emit<P extends keyof T, K extends T[P]>(event: P, ...args: K): boolean;
keyof T
は Tに含まれるプロパティ名の集合です。
{
hoge: [string, number],
fuga: [number, number?],
piyo: []
}
の場合は hoge | fuga | piyo
となります。
T[P]
は Tに含まれるプロパティPを示します。
上記例ではT["hoge"]
で[string, number]
となります。非常に便利です。
enumとはなんだったのか...
...args: K
は配列Kを可変引数に展開しますが、上記例のようにKが tupleで指定されていた場合でもtuple内の型を保ったまま展開されます。
...[number, string] ---> number, string
簡易ラッパーを作りIDE補完を効かせる
ここまでで型安全は保証されるためコードの保守性は問題無いですが、各関数のイベント名は文字列で入力する必要があり、コード補完が効き辛く効率化されているとは言い難いです。
そこで Mapped types を利用した簡易ラッパーを作成してIDEの機能を有効活用出来るようにしてみます。
interface は以下のように定義します。
export type Nullable<V> = V | null | undefined;
export type MapToNullable<T> = { [K in keyof T]: Nullable<T[K]> };
export interface IIndividualEmitter<V extends any[], P extends ( string | symbol) = any> {
addListener(listener: (...args: V) => void): this;
emit<U extends MapToNullable<V>>(...args: U ): boolean;
eventName(): P;
listenerCount(): number;
listeners(): Array<(...args: V) => void>;
off(listener: (...args: V) => void): this;
on(listener: (...args: V) => void): this;
once(listener: (...args: V) => void): this;
removeAllListeners(): this;
removeListener(listener: (...args: V) => void): this;
}
export interface IEventEmitterWrapper<T extends EventDef> {
readonly originalEmitter: ITypedEmitter<T>;
readonly events: {[P in keyof T]: IIndividualEmitter<T[P]>}
}
実装はこうしました。
import {EventEmitter} from "events";
class IndividualEmitter<T extends {[P in string | symbol]: any[]}, K extends keyof T, V extends any[]> implements IIndividualEmitter<V> {
constructor(
private emitter: ITypedEmitter<T>,
private name: K,
private defaultValues: V,
){}
public addListener(listener: (...args: V) => void): this {
this.emitter.addListener(this.name, listener);
return this;
}
public emit<U extends MapToNullable<V>>(...args: U): boolean {
return this.emitter.emit(this.name, ...(this.defaultValues.map((defaultValue, index) => {
const val = args[index];
return val != null ? val : defaultValue;
})));
}
public eventName(): K {
return this.name;
}
public listenerCount(): number {
return this.emitter.listenerCount(this.name);
}
public listeners(): Array<(...args: V) => void> {
return this.emitter.listeners(this.name);
}
public off(listener: (...args: V) => void): this {
this.emitter.off(this.name, listener);
return this;
}
public on(listener: (...args: V) => void): this {
this.emitter.on(this.name, listener);
return this;
}
public once(listener: (...args: V) => void): this {
this.emitter.once(this.name, listener);
return this;
}
public removeAllListeners(): this {
this.emitter.removeAllListeners(this.name);
return this;
}
public removeListener(listener: (...args: V) => void): this {
this.emitter.removeListener(this.name, listener);
return this;
}
}
export function createEventEmitterWrapper<T extends {[P in string | symbol]: any[]}, E extends EventEmitter = any>(
emitter: E,
eventDef: T,
): IEventEmitterWrapper<T> {
return Object.keys(eventDef).reduce((res: any, key) => {
res.events[key] = new IndividualEmitter(emitter as any, key, eventDef[key]);
return res;
},{
events: {},
originalEmitter: emitter,
});
}
使い方は、
interface ISample extends EventDef {
fuga: [number, number?],
hoge: [string, number],
piyo: []
}
const events = createEventEmitterWrapper<ISample>(new EventEmitter() as any, {
fuga: [1, 2],
hoge: ["foo", 0],
piyo: [],
}).events;
events.hoge.on((val1, val2) => { console.log("hogehoge") }); // ok
events.hoge.emit("hogehoge", 1); // ok
events.hoge.emit("hogehoge", "1"); // エラー
events.hoge.emit(null, 1); // ok /・・・引数1はデフォルト値「foo」が設定される
events.fuga.emit(1, 2); // ok
events.fuga.emit(1); // ok・・・引数2はデフォルト値「2」が適用される
events.fuga.emit( 1, 2, 3); // エラー
events.piyo.emit(); // ok
VSCodeでの補完例です。イベント名(hoge, fuga, piyo)がIDE補完で入力可能となります。
Mapped typesによる変換
以下のようにkeyof
と組み合わせることで元のプロパティ名を保ったまま値のみが異なる新しいオブジェクトを定義出来ます。
readonly events: {[P in keyof T]: IIndividualEmitter<T[P]>}
このように記述すればEventDef
で効いていた補完がevents
にも適用されます。
実際に実装する場合は(Object.keys()
でキーを取得するための)同一名プロパティ(key)を持つObjectの実体が必要となります。
インスタンス生成部の実装
export function createEventEmitterWrapper<T extends {[P in string | symbol]: any[]}, E extends EventEmitter = any>(
emitter: E,
eventDef: T,
): IEventEmitterWrapper<T> {
return Object.keys(eventDef).reduce((res: any, key) => {
res.events[key] = new IndividualEmitter(emitter as any, key, eventDef[key]);
return res;
},{
events: {},
originalEmitter: emitter,
});
}
Mapped typesのtuple適用を利用した、引数へのnull許容化
今回のようなケースでは初期化時はkeyさえあれば良く、プロパティ値は無くとも良いですが、
ここでは TypeScript3.1.1で追加された Mapped typesの tuple および arrayへの適用を利用して、
初期化時のプロパティ値をデフォルト値とし、emit
関数が呼び出された時、引数にnull
が指定された場合に補完するものとしています。
※Mapped typesのarray適用を使ってみたいと思って書いただけなので、デフォルト値が要らない場合は無視してください。
emit<P extends keyof T, K extends T[P]>(...args: K): boolean;
この形では引数にnullが指定出来ないので
export type Nullable<V> = V | null | undefined;
export type MapToNullable<T> = { [K in keyof T]: Nullable<T[K]> };
この Mapped types を引数の tuple に適用することでnull、undefinedを許容するようになります。
emit<U extends MapToNullable<V>>(...args: U ): boolean;
emit関数補完の実装
public emit<U extends MapToNullable<V>>(...args: U): boolean {
return this.emitter.emit(this.name, ...(this.defaultValues.map((defaultValue, index) => {
const val = args[index];
return val != null ? val : defaultValue;
})));
}
このように記述すれば引数にnullが指定出来るようになり、その場合にインスタンス生成時に指定されたデフォルト値が適用されるようになります。
おわりに
慣れるまでが大変ですが、これらは手軽にオレオレフレームワークを作ってプロジェクトに適用するハードルを下げるのにも役立ちますし、型安全だけでは無く効率化ももたらします。
ちなみに自分はWebStorm派です。