TypeScript

TypeScript 2.8 の Conditional Types について

今日はTypeScript2.8で導入されたConditional typesについて書きます。
TypeScriptの型システムへの機能追加という意味では、keyofやMapped Types以来の変更と言ってよいでしょう。

お察しのとおり、こういう機能追加をぶち込んでくるのはAnders Hejlsberg御大です1

Conditional Types

Conditional Typesは読んで字の如く、型定義における条件分岐です。次の構文で表現します。

type MyCondition<T, U, X, Y> = T extends U ? X : Y;

三項演算子と同様の記法なので直感的に理解できると思いますが、「TがUに代入可能であればXを、そうでなければY」という型を表します。

また、Conditional typesには次の性質があります。

  • 遅延評価: X, Yの決定に対して、T, Uという型変数への依存がある場合、型の解決はT, Uが決定されるまで評価が遅延される
  • Union typesの分配則: Union typesのConditional Typesは、それぞれのConditional TypesのUnionに展開される。すなわち、 (T1 | T2) extends U ? X : Y = (T1 extends U ? X : Y) | (T2 extends U ? X : Y)2

例えば、次のように Diff という型を作ったとします。

type Diff<T, U> = T extends U ? never : T;

遅延評価により、この時点ではこの型は未決定です。型パラメータT, Uに下記を与えて評価させます。

Diff<"hoge" | "foo" | "piyo", "foo">

Union typesについての分配律を考えれば、解決される型は "hoge" | never | "piyo" = "hoge" | "piyo" ですね。

例1: Flowの$Diff

次のコードは、FlowのUtility Typesにおける$Diffから引っ張ってきています。

type Props = { name: string; age: number };
type DefaultProps = { age: number };
type RequiredProps = $Diff<Props, DefaultProps>; // これ

declare function setProps<T extends RequiredProps>(props: T): void;

setProps({ name: "foo" });
setProps({ name: "foo", age: 42 }); // you can pass extra props too
setProps({ age: 42 }); // error, name is requred

Conditional Typesを使って、これと同じものを表現してみましょう。
ちなみに、このネタは TypeScriptでDiff型を表現する にConditional Typesを使わずに実現する例があるので、対比して読むと良いと思います。

どちらのやり方においても、肝となるのはDiffTypeのkeyを絞り込む部分です。上記の例に即して言うならば、以下のRequiredKeys をどのように作るか、ということです。

type RequiredKeys = "age" // "name" | "age" と "age" の差集合

これは先ほど見た Diffという型そのものですね。

type Diff<T, U> = T extends U ? never : T;

type RequiredKeys = Diff<"age" | "name", "age">; // "name"

RequiredKeys相当を表現できてしまえば、あとは下記で$Diffが作れます。

type $Diff<T, U> = { [P in Diff<keyof T, keyof U>]: T[P] };

/* こちらでも可 */
type $Diff<T, U> = Pick<T, Diff<keyof T, keyof U>>;

Type inference in conditional types

Conditional typesが、型におけるマッチングを可能にしたわけですが、マッチング中にキャプチャした型を再利用できます。
それが Type inference in Conditional typesです。

Conditional type T extends U ? X : Y の条件(Uのとこ)に infer S と書くと、Sに補足された型を X の部分で再利用可能になります。

なんのこっちゃと思った方は正規表現の() を想像してください。/hoge(\d+)$/ に対して、hoge1の末尾数字部分をあとから参照可能になるじゃないですか、これと同じです。

以下に具体的な使用例を書いてみます。

type ReturnType<T> = T extends ((...args: any[]) => infer R) ? R : never;

extendsの直後に注目します。 (...args: any[]) => any が「関数なんでも」ですよね。この戻り値部分を infer R と書き換えたものが、マッチング対象の型である (...args: any[]) => infer R です。
infer R「マッチした際にその部分に推論される型をRにキャプチャする」という意味なので、最終的にこの ReturnType<T> という型は「Tが関数であればその戻り値型」を表すことになります。

別の例を示しましょう。

type ResolvedType<T> =
  T extends Promise<infer R> ? R :
  T extends Observable<infer R> ? R :
  T;

非同期の中身を取り出す型がかけます。

例2: Sinon.jsのStub

infer を使った例として、Sinon.jsの型定義を(一部)書いてみます。

Sinon.jsはテストで用いられることの多いユーティリティライブラリです。
テスト対象のオブジェクトが、さらに別のオブジェクトに依存しているケースでは、依存対象をスタブに置き換える、というのをよくやると思います。
Sinon.jsでも、スタブを簡単に作成する為のStub APIが用意されています。

StubAPI利用例
const dependentService = {
  methodToBeStubbed(param: string): string {
    // Too complex procedure
    return "SomeStringHardToFetch";
  }
};

const stub = sinon.stub(dependentService, "methodToBeStubbed");
stub.withArgs("some param").returns("stub value");

assert.equals(dependentService.methodToBeStubbed("some param"), "stub value");

上記のStub APIに対して infer を使って型付けしてみます。

ここでの肝は、withArgsreturns において関数を構成する引数や戻り値の型が必要となる部分です。

Conditional typesとInferenceを利用して、これらを取り出す型定義を作っていきます。

type ReturnType<T> = T extends ((...args: any[]) => infer R) ? R : never;
type FirstArgs<T> = T extends (a1: infer A1, ...rest: any[]) => any ? A1 : never;
type SecondArgs<T> = T extends (a1: any, a2: infer A2, ...rest: any[]) => any ? A2 : never;
type AllArgs<T> = T extends (...args: (infer A)[]) => any ? A : never;

一番上のReturnTypeは、先ほど例示した型と同様です。
引数については、任意個数、任意型のパターンを汎用的に受ける方法は(多分)存在しないはずなので、2個までで諦めました3

これらを使って、sinon.stub を完成させましょう。こんな感じかな。

declare namespace sinon {

  function stub<T, K extends keyof T>(target: T, methodName: K): Stub<T, K>;

  interface Stub<T, K extends keyof T> {
    returns(v: ReturnType<T[K]>): this;
    withArgs(a1: FirstArgs<T[K]>): this;
    withArgs(a1: FirstArgs<T[K]>, a2: SecondArgs<T[K]>): this;
    withArgs(...args: AllArgs<T[K]>[]): this;
  }

}

もう一度、sinon.stubの利用コードを眺めてみると、

const stub = sinon.stub(dependentService, "methodToBeStubbed");
stub.withArgs("some param").returns("stub value");

もしもこのコードがただのJavaScriptであれば、どこか1つ型を間違えたり、typoしたらテストは成り立ちませんが、一々テストを実行しないとそれに気付けません。
一方TypeScriptあれば、型を間違ったスタブを静的に検知できるのです。

例3 Immutable.jsのfromJS

最後にConditional typesとinfer を使った例をもう1つ書いてみました。

題材はImmutable.jsです。
Immutable.jsには、与えられたJSONオブジェクトをImmutable.jsのmapに再帰的に変換する fromJS 関数というのがあります。

const deepMap = Immutable.fromJS({
  a: 1,
  b: {
    c: "2",
    d: [3, 4],
  },
});

assert.equal(deepMap.get("a"), 1);
assert.equal(deepMap.get("b").get("c"), "2");
assert.equal(deepMap.get("b").get("d").get(0), 3);

型定義は下記のようにかけます。get の戻り値型は、元々のJSONのvalueによって場合分けが必要なうえ、特にArrayの場合は「何型のArrayなのか」という情報まで必要になりますが、これは正にConditional typesとinferで解決できるパターンですね。

declare namespace Immutable {
  type DeepImmutable<T> =
    T extends (infer R)[] ? DeepImmutableArray<R> :
    T extends object ? DeepImmutableMap<T> :
    T;

  interface DeepImmutableMap<T> {
    get<K extends keyof T>(key: K): DeepImmutable<T[K]>;
  }

  interface DeepImmutableArray<T> {
    get(idx: number): DeepImmutable<T>;
  }

  function fromJS<T>(source: T): DeepImmutable<T>;
  function toJS<T>(obj: DeepImmutable<T>): T;
}

おわりに

このエントリでは、TypeScript 2.8で導入される Conditional typesと inferを解説しました。

これまでのTypeScriptでも、メソッドの多重定義やIntersection typesの利用で、ちょっとした条件分岐チックなようなものが書けないではなかったのですが、Conditional Typeのお陰で、型の条件分岐がより直感的にかけるようになりました。
2.1でMapped Typesが導入されたときもそうでしたが、この手の型合成テクニックは、普段からバリバリ使うようなものにはならないと思いますが、anyを使わざるを得なかったようなライブラリの型定義もより厳密にかけるようになるのは嬉しいですね。


  1. https://github.com/Microsoft/TypeScript/pull/21316https://github.com/Microsoft/TypeScript/pull/21496 です。 

  2. 同様にIntersection typesについても分配則が成り立ちます 

  3. rxjsの型定義などでも、まともに型定義されている(=オーバーロードされている)のは、引数個数が6~7個程度までだったはず。