気付いたらTS 3.1.1がしれっとリリースされていたので、今回導入されたMapped Tuple Typeについて書いてみようと思います。
対応するPRはこちら。
今回のMapped Tuple Typeは、TypeScript 2.1で導入されたMapped Typeの強化版です。
ちなみに「Mapped Type is 何」という人は、以前に解説記事を書いたのでこれを読むと良いかと。
さて、Mapped Tuple Type、別に新しい記法とかが追加されたわけではありません。
次のコードは上記のPRに記載されているサンプルです。
type Box<T> = { value: T };
type Boxified<T> = { [P in keyof T]: Box<T[P]> };
type T1 = Boxified<string[]>; // Box<string>[]
type T2 = Boxified<ReadonlyArray<string>>; // ReadonlyArray<Box<string>>
type T3 = Boxified<[number, string?]>; // [Box<number>, Box<string>?]
type T4 = Boxified<[number, ...string[]]>; // [Box<number>, ...Box<string>[]]
type T5 = Boxified<string[] | undefined>; // Box<string>[] | undefined
type T6 = Boxified<(string | undefined)[]>; // Box<string | undefined>[]
type Boxified<T> = { [P in keyof T]: Box<T[P]> };
の行がMapped Typeの記法です。
この記法は3.0以前となんら変わりはありません。
問題はこの型パラメータ T
に配列が代入された場合です。
従来は、 keyof string[] = "pop" | "push" | "concat" | "join" | "reverse" | "shift" | "slice" // 以下略
のようにArrayに定義されているメソッド名に対してMapされてしまいました。
これが3.1以降では、あたかも keyof string[] = number
のように振る舞います。
といっても、 keyof
の意味に変更がなされたわけではなく、飽くまでMapped Typeのマッピング挙動が変更された、ということのようです。
マッピング解釈の新しいルールは次のようになっています。
-
T
が基本型の場合、マッピングは行われない -
T
がunion typeの場合、それぞれのMapped Typeのunionに分配される -
T
がS[]
の場合、R[]
にマッピングする。R
はT[P]
をS
に置き換えたX
のインスタンス -
T
がReadOnlyArray<S>
の場合、ReadOnlyArray<R>
にマッピングする。R
はT[P]
をS
に置き換えたX
のインスタンス -
T
が[S0, S1, ..., Sn]
である場合、[R0, R1, ..., Rn]
にマッピングする。Rx
はT[P]
をSx
に置き換えたX
のインスタンス
要するに「Array Likeな型のMapped Typeは、その配列の要素に対してマッピングが行われる」と解釈しておけば大体あっているはず。
このMapped Tuple Typeがどこで役に立つかというと、 やはりBoxing/Unboxingと配列が絡むような関数の型定義でしょう。
たとえばrxjsには combineLatest
という関数が定義されています。
こいつは複数個のObservableを引数として受け取ると、それらのストリームの最新の値のタプルのストリームを返してくれる関数です。
この関数をバカ正直に型定義すると、次のようになります。
declare namespace rx {
interface Observable<T> {
subscribe(cb: (v: T) => void): void;
}
function combineLatest(...streams: Observable<any>[]): Observable<any[]>;
}
でもこれは any
ばかりになってしまい、全然型の恩恵を受けられないので、こういった関数の型定義は次のように書いたりしていました。
declare namespace rx {
interface Observable<T> {
subscribe(cb: (v: T) => void): void;
}
function combineLatest<T1, T2>(s1: Observable<T1>, s2: Observable<T2>): Observable<[T1, T2]>;
function combineLatest<T1, T2, T3>(s1: Observable<T1>, s2: Observable<T2>, s3: Observable<T3>): Observable<[T1, T2, T3]>;
// 大体T6とかT7くらいまで続いてたりする
function combineLatest(...streams: Observable<any>[]): Observable<any[]>;
}
この関数をMapped Tuple Typeを使って書き直すと、次のようになるわけですね。
declare namespace rx {
interface Observable<T> {
subscribe(cb: (v: T) => void): void;
}
type StreamValue<S> = S extends Observable<infer T> ? T : never;
type Unwrap<S> = { [P in keyof S]: StreamValue<S[P]> };
function combineLatest<U extends Observable<any>[]>(...streams: U): Observable<Unwrap<U>>;
}
今回は Observable<T>
の中身を取り出したいので、「Observableの中身を取り出すConditional Type」をMapped Typeに食わせています。
下記のようなテストコードを書いて、s1
や s2
の型をVSCなどのツールチップで見てみてると、ちゃんと任意個数の引数について、中身の型を取り出せていることが確認できます。
function test(s1$: rx.Observable<string>, s2$: rx.Observable<number>, s3$: rx.Observable<string>) {
rx.combineLatest(s1$, s2$).subscribe(([s1, s2]) => console.log(s1, s2));
rx.combineLatest(s1$, s2$, s3$).subscribe(([s1, s2, s3]) => console.log(s1, s2, s3));
}
そういえば今回の変更は「Mapped Typeの解釈が変わった」という意味ではBreaking Changeじゃないの?と思わないでもないですが、3.1 RC アナウンスのblog には次のように書いてありました。
While technically consistent in behavior, the majority of our team felt that this use-case should just work. Rather than introduce a new concept for mapping over a tuple, mapped object types now just “do the right thing” when iterating over tuples and arrays.
わざわざ新しい記法を導入するよりも「大多数の人が望む挙動」に修正することを選んだ、とのことらしいです。