白猫のメモ帳

C#とかJavaとかJavaScriptとかHTMLとか機械学習とか。

TypeScriptの型を復習する その4

こんばんは。
雑多に本を買いすぎて積み本が増えてきました。

今回はConditional Typesを見ていきます。名前の通り条件分岐ができる型です。
なんでそんなことがしたいのかとか、どういうときに役に立つのかという話を先にしたいのですが、そもそも文法を知らないと話が進まないので、先に見ていきましょう。

条件型(Conditional Types)

型を要求する場面でextendsキーワードを使って三項演算子のように条件分岐を行います。
ちなみにこのextendsはジェネリック型の制約で使うextendsとは別物です。

test('基本形', () => {
    type IsString<T> = T extends string ? true : false;
    type A = IsString<string>;
    type B = IsString<number>;
    expectTypeOf<A>().toEqualTypeOf<true>();
    expectTypeOf<B>().toEqualTypeOf<false>();
});

ここで取れるtrueとかfalseは値ではなく型なので、リテラルなのがなかなかややこしいですね。

分配(Distributive Conditional Types)

条件型の特徴的な挙動として、分配というものがあります。
これは条件型がUnion型に対して適用された場合に、Union型の各メンバーに対して個別に条件型が適用されるというものです。

test('分配', () => {
    type ToStringIfNumber<T> = T extends number ? string : T;
    type A = ToStringIfNumber<number | boolean>;
    expectTypeOf<A>().toEqualTypeOf<string | boolean>();
});

この例では数値型なら文字列型にするという「ToStringIfNumber」という条件型が定義されています。
この時に「number型もしくはboolean型」に対して「number型ではないので変換しない」という挙動よりも、「number型ならstring型に、boolean型なら変換しない」と分解されてそれぞれに条件型が適用され、その結果が再びUnion型として結合されたほうが便利というのは納得感がありますね。

ちなみに明示的に分配したくない時には大括弧を使います。
ここでの[]は特殊な文法ではなく要素が一つだけのタプル型を作るための構文です。
分配はnaked型に対してだけ起こるので、タプル型にすることでUnion型が分解されなくなります。

test('分配を防ぐ', () => {
    type ToStringIfNumber<T> = [T] extends [number] ? string : T;
    type A = ToStringIfNumber<number | boolean>;
    expectTypeOf<A>().toEqualTypeOf<number | boolean>();
});

inferキーワード

inferは条件型の中でのみ使える特殊なキーワードで、型を推論するために使います。使う場所はextendsキーワードの右辺です。
ニュアンスとしては型のパターンマッチングみたいな感じでしょうか。
ちなみにextendsキーワードの左辺は引数として渡されるのでinferがなくても参照できます。推測しなくてもそのものずばりですしね。

test('infer', () => {
    type GetArrayElementType<T> = T extends Array<infer U> ? U : T;
    type A = GetArrayElementType<string[]>;
    type B = GetArrayElementType<number>;
    expectTypeOf<A>().toEqualTypeOf<string>();
    expectTypeOf<B>().toEqualTypeOf<number>();
});

で、いつなんで使うの?

はい、というわけで条件型、分配、inferキーワードをささっと確認しました。
ここで改めてなんでそんなことがしたいのか、どういうときに役に立つのかを考えてみましょう。

ここまでの例では型だけを変換していてなかなかピンとこないですが、関数と組み合わせると入力の型に応じて返す型を変えるみたいなことができます。
引数の型を使って戻り値の型を変更するというのはジェネリック型の典型的な使い方ですが、その拡張ができるわけです。

test('関数の戻り値の型を変える', () => {
    type ToStringIfNumber<T> = T extends number ? string : T;
    function toStringIfNumber<T>(value: T): ToStringIfNumber<T> {
        if (typeof value === 'number') {
            return value.toString() as ToStringIfNumber<T>;
        } else {
            return value as ToStringIfNumber<T>;
        }
    }
    expect(toStringIfNumber(123)).toBe("123");
    expect(toStringIfNumber(true)).toBe(true);
});

そして条件型はTypeScript標準ライブラリの中でも多用されています。
前回の記事で確認したUtility Typesも条件型を使って実装されているものがいくつかあります。

Excludeはユニオン型から指定した型を除外した型を生成しますが、これはまさに条件型と分配の組み合わせです。
このようにユニオン型の中から「条件に合うものだけ」を取り出したいという場面でも使えます。

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

ReturnTypeは関数の戻り値の型を取得しますが、これは条件型とinferキーワードの組み合わせです。
このように、ある型パターンから「一部の型」を取り出したいというニーズでも便利ですね。

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

そんなわけで、型から特定の情報を取り出したり、型を変換したりするような場面がTypeScriptではよくあります。
そのような場合に「条件型をうまく使うとその表現力が上がって便利だよね」というのが、いつなんで使うのに対する答えになります。

これでTypeScriptの型を復習するシリーズは終了の予定です。
もしかしたらReactとかの型を見て何がどうなってるのか見てみるとかの番外編を書くかもしれませんが、いったんはここまでということで。