TypeScript Conditional Types 公式ドキュメント
はじめに
TypeScript の Conditional Types
は、入力の型に基づいて型を決定する仕組みです。
TypeScript 公式ドキュメントの内容に基づき、Conditional Types
の基本概念から応用的な使用方法まで、コード例とともに解説します。
Conditional Types
多くの有用なプログラムの中核では、入力に基づいて決定を下す必要があります。JavaScript プログラムも例外ではありませんが、値を簡単に内省できるという事実を考えると、これらの決定は入力の型にも基づいています。Conditional Types は、入力と出力の型の関係を記述するのに役立ちます。
interface Animal {
live(): void;
}
interface Dog extends Animal {
woof(): void;
}
type Example1 = Dog extends Animal ? number : string;
// ^? type Example1 = number
type Example2 = RegExp extends Animal ? number : string;
// ^? type Example2 = string
Conditional Types は、JavaScript の条件式(condition ? trueExpression : falseExpression
)と少し似た形を取ります:
SomeType extends OtherType ? TrueType : FalseType;
extends
の左側の型が右側の型に代入可能である場合、最初の分岐("true" 分岐)で型を取得します。そうでなければ、後者の分岐("false" 分岐)で型を取得します。
上記の例から、conditional types はすぐには有用でないかもしれません - Dog extends Animal
であるかどうかを自分で判断し、number
または string
を選択できます!しかし、conditional types の力は、ジェネリクスと組み合わせて使用することから生まれます。
例えば、次の createLabel
関数を見てみましょう:
interface IdLabel {
id: number /* some fields */;
}
interface NameLabel {
name: string /* other fields */;
}
function createLabel(id: number): IdLabel;
function createLabel(name: string): NameLabel;
function createLabel(nameOrId: string | number): IdLabel | NameLabel;
function createLabel(nameOrId: string | number): IdLabel | NameLabel {
throw "unimplemented";
}
createLabel のこれらのオーバーロードは、入力の型に基づいて選択を行う単一の JavaScript 関数を記述しています。いくつかのことに注意してください:
- ライブラリが API 全体で同じ種類の選択を何度も繰り返す必要がある場合、これは煩雑になります
- 3 つのオーバーロードを作成する必要があります:型が確実な場合の各ケース(
string
とnumber
それぞれ 1 つ)と、最も一般的なケース(string | number
を取る)。createLabel
が処理できる新しい型ごとに、オーバーロードの数は指数関数的に増加します
代わりに、そのロジックを conditional type でエンコードできます:
type NameOrId<T extends number | string> = T extends number
? IdLabel
: NameLabel;
その後、その conditional type を使用して、オーバーロードを持たない単一の関数にオーバーロードを簡素化できます。
function createLabel<T extends number | string>(idOrName: T): NameOrId<T> {
throw "unimplemented";
}
let a = createLabel("typescript");
// ^? let a: NameLabel
let b = createLabel(2.8);
// ^? let b: IdLabel
let c = createLabel(Math.random() ? "hello" : 42);
// ^? let c: NameLabel | IdLabel
Conditional Type Constraints
多くの場合、conditional type でのチェックは新しい情報を提供してくれます。型ガードによる narrowing がより具体的な型を与えてくれるのと同じように、conditional type の true 分岐は、チェック対象の型によってジェネリクスをさらに制約します。
例えば、次のコードを見てみましょう:
type MessageOf<T> = T["message"];
// ~~~~~~~
// Property 'message' does not exist on type 'T'.
この例では、TypeScript は T
に message
というプロパティがあることが分からないためエラーになります。T
を制約することで、TypeScript はもはや文句を言わなくなります:
type MessageOf<T extends { message: unknown }> = T["message"];
interface Email {
message: string;
}
type EmailMessageContents = MessageOf<Email>;
// ^? type EmailMessageContents = string
しかし、MessageOf
が任意の型を取り、message
プロパティが利用できない場合に never
のようなものをデフォルトにしたい場合はどうでしょうか?制約を移動して conditional type を導入することでこれを実現できます:
type MessageOf<T> = T extends { message: unknown } ? T["message"] : never;
interface Email {
message: string;
}
interface Dog {
bark(): void;
}
type EmailMessageContents = MessageOf<Email>;
// ^? type EmailMessageContents = string
type DogMessageContents = MessageOf<Dog>;
// ^? type DogMessageContents = never
true 分岐内で、TypeScript は T
が message
プロパティを持つことを知っています。
別の例として、配列型をその要素型にフラット化し、それ以外はそのまま残す Flatten
という型も書けます:
type Flatten<T> = T extends any[] ? T[number] : T;
// 要素型を抽出
type Str = Flatten<string[]>;
// ^? type Str = string
// 型をそのまま残す
type Num = Flatten<number>;
// ^? type Num = number
Flatten
が配列型を与えられると、number
を使った indexed access で string[]
の要素型を取得します。そうでなければ、与えられた型をそのまま返します。
Inferring Within Conditional Types
conditional types を使用して制約を適用し、型を抽出することがよくあります。これは非常に一般的な操作であるため、conditional types はこれを簡単にします。
Conditional types は、true 分岐で比較対象の型から推論する方法を infer
キーワードを使用して提供します。例えば、indexed access type で「手動」で取得する代わりに、Flatten
で要素型を推論できます:
type Flatten<Type> = Type extends Array<infer Item> ? Item : Type;
ここでは、true 分岐内で Type
の要素型を取得する方法を指定する代わりに、infer
キーワードを使用して Item
という名前の新しいジェネリック型変数を宣言的に導入しました。これにより、興味のある型の構造を掘り下げて調査する方法について考える必要がなくなります。
infer
キーワードを使用していくつかの有用なヘルパー型エイリアスを書くことができます。例えば、簡単なケースでは、関数型から戻り値の型を抽出できます:
type GetReturnType<Type> = Type extends (...args: never[]) => infer Return
? Return
: never;
type Num = GetReturnType<() => number>;
// ^? type Num = number
type Str = GetReturnType<(x: string) => string>;
// ^? type Str = string
type Bools = GetReturnType<(a: boolean, b: boolean) => boolean[]>;
// ^? type Bools = boolean[]
複数のコールシグネチャを持つ型(オーバーロードされた関数の型など)から推論する場合、推論は最後のシグネチャから行われます(おそらく最も寛容なキャッチオールケース)。引数型のリストに基づいてオーバーロード解決を実行することはできません。
declare function stringOrNum(x: string): number;
declare function stringOrNum(x: number): string;
declare function stringOrNum(x: string | number): string | number;
type T1 = ReturnType<typeof stringOrNum>;
// ^? type T1 = string | number
Distributive Conditional Types
conditional types がジェネリック型に作用する場合、union type が与えられると分散的になります。例えば、次のコードを見てみましょう:
type ToArray<Type> = Type extends any ? Type[] : never;
ToArray
に union type を渡すと、conditional type がその union の各メンバーに適用されます。
type ToArray<Type> = Type extends any ? Type[] : never;
type StrArrOrNumArr = ToArray<string | number>;
// ^? type StrArrOrNumArr = string[] | number[]
ここで起こることは、ToArray
が次のものに分散することです:
string | number;
そして union の各メンバー型にマップし、実際には次のようになります:
ToArray<string> | ToArray<number>;
これにより次のような結果になります:
string[] | number[];
通常、分散性は望ましい動作です。その動作を避けるには、extends
キーワードの両側を角括弧で囲むことができます。
type ToArrayNonDist<Type> = [Type] extends [any] ? Type[] : never;
// 'ArrOfStrOrNum' はもはや union ではありません
type ArrOfStrOrNum = ToArrayNonDist<string | number>;
// ^? type ArrOfStrOrNum = (string | number)[]
まとめ
TypeScript の Conditional Types
を使用することで、入力の型に基づいて型を動的に決定できます。
公式ドキュメントで紹介されている主要な概念:
-
基本的な条件分岐:
T extends U ? X : Y
- 制約での新しい情報の活用: true 分岐での型の絞り込み
-
infer による型推論:
infer
キーワードを使った型の抽出 - 分散的 conditional types: union type での自動的な分散
重要なポイント
- Conditional types はジェネリクスと組み合わせることで真価を発揮
- true 分岐では TypeScript が追加の型情報を把握
-
infer
により型の構造から要素を宣言的に抽出可能 - Union type では各メンバーに分散的に適用される(制御可能)
この機能により、型レベルでの条件分岐を実現し、より柔軟で安全な型システムを構築できます。
参考リンク
Discussion