LoginSignup
98
57

More than 5 years have passed since last update.

TypeScript 3.1 の Mapped Tuple Typeについて

Last updated at Posted at 2018-10-01

気付いたら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.

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

98
57
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
98
57