TypeScript

TypeScript 3.1 の Mapped Tuple Typeについて

気付いたら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に分配される
  • TS[] の場合、R[] にマッピングする。 RT[P]S に置き換えた X のインスタンス
  • TReadOnlyArray<S> の場合、 ReadOnlyArray<R> にマッピングする。 RT[P]S に置き換えた X のインスタンス
  • T[S0, S1, ..., Sn] である場合、 [R0, R1, ..., Rn] にマッピングする。RxT[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に食わせています。

下記のようなテストコードを書いて、s1s2 の型を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.

わざわざ新しい記法を導入するよりも「大多数の人が望む挙動」に修正することを選んだ、とのことらしいです。