TypeScriptで高度な型定義を行う際、避けて通れない機能がConditional Types(条件付き型)です。三項演算子に似たT extends U ? X : Yという構文を使い、型の世界で条件分岐を表現できます。
ExtractやReturnTypeなどの組み込みユーティリティ型も、内部ではConditional Typesで実装されています。仕組みを理解すれば、プロジェクト固有の型ユーティリティを自作し、コードベース全体の型安全性を引き上げることが可能です。
Conditional Typesの基本構文
Conditional Typesは TypeScript 2.8(2018年3月リリース)で導入された機能で、型レベルのif文に相当します。構文は次のとおりです。
type Result = T extends U ? X : Y;
TがUに割り当て可能(assignable)であれば型Xに、そうでなければ型Yに解決されます。ここでのextendsは、クラスの継承とは異なり「部分型関係(subtype relation)」を判定するキーワードです。
具体例として、渡された型がstringかどうかを判定する型を作ってみます。
type IsString<T> = T extends string ? "yes" : "no";
type A = IsString<"hello">; // "yes"
type B = IsString<42>; // "no"
type C = IsString<string>; // "yes"
"hello"はリテラル型としてstringの部分型なので"yes"に解決されます。42はnumber型でありstringには割り当てられないため"no"になります。
extendsの割り当て可能性を理解する
Conditional Typesのextendsは「左辺が右辺に代入できるか」をチェックします。オブジェクト型の場合、プロパティが多い方がより具体的であり、少ない方に割り当て可能です。
type HasName = { name: string };
type HasNameAndAge = { name: string; age: number };
// HasNameAndAge は HasName のプロパティをすべて持つので割り当て可能
type Check1 = HasNameAndAge extends HasName ? true : false; // true
// 逆方向は不可(age が足りない)
type Check2 = HasName extends HasNameAndAge ? true : false; // false
この性質を利用すると、特定のプロパティを持つ型だけをフィルタリングするような条件型を構築できます。
ジェネリクスと組み合わせた型制約
Conditional Typesが最も力を発揮するのは、ジェネリクス(Generics)との併用です。型パラメータTに対してConditional Typesを適用すると、Tに渡される具体的な型に応じて結果が動的に変わります。
type Unwrap<T> = T extends Array<infer Elem> ? Elem : T;
type S = Unwrap<string[]>; // string
type N = Unwrap<number>; // number(配列でないのでそのまま)
このパターンは、関数のオーバーロードの代わりとしても利用できます。引数の型に応じて戻り値の型を切り替えるジェネリック関数を定義する例です。
type ApiResponse<T extends "user" | "post"> =
T extends "user"
? { id: number; name: string; email: string }
: { id: number; title: string; body: string };
function fetchData<T extends "user" | "post">(
endpoint: T
): Promise<ApiResponse<T>> {
return fetch(`/api/${endpoint}`).then((res) => res.json());
}
// 戻り値の型が自動的に推論される
const user = await fetchData("user");
// ^? Promise<{ id: number; name: string; email: string }>
const post = await fetchData("post");
// ^? Promise<{ id: number; title: string; body: string }>
inferキーワードで型を抽出する
inferはConditional Typesのtrue分岐(extends条件が成立する側)でのみ使用できるキーワードで、パターンマッチによる型の抽出を可能にします。TypeScript 2.8でConditional Typesと同時に導入されました。
関数の戻り値型を取得する
組み込みのReturnType<T>は、内部的にinferを使って実装されています。同等の型を自作すると次のようになります。
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type R1 = MyReturnType<() => string>; // string
type R2 = MyReturnType<(x: number) => boolean>; // boolean
type R3 = MyReturnType<string>; // never
infer Rは「この位置にある型をRとして取り出す」という宣言です。関数型のパターンにマッチすれば戻り値の型がRに束縛され、マッチしなければneverに解決されます。
引数の型を取得する
関数の引数リスト全体をタプル型として取り出すこともできます。
type MyParameters<T> = T extends (...args: infer P) => any ? P : never;
type P1 = MyParameters<(a: string, b: number) => void>;
// ^? [a: string, b: number]
Promiseの中身を再帰的に取り出す
Awaited<T>はTypeScript 4.5で追加された組み込み型で、ネストしたPromiseを再帰的にアンラップします。簡略化した自作バージョンは次のとおりです。
type MyAwaited<T> = T extends Promise<infer U> ? MyAwaited<U> : T;
type A1 = MyAwaited<Promise<string>>; // string
type A2 = MyAwaited<Promise<Promise<number>>>; // number
type A3 = MyAwaited<boolean>; // boolean
再帰的なConditional Typesにより、Promiseが何重にネストしていても最終的な値の型を取り出せます。
タプル型の先頭・末尾を操作する
inferはタプル型の可変長引数(variadic tuple types)と組み合わせることで、リストの先頭や末尾を分解できます。
type Head<T extends any[]> = T extends [infer First, ...any[]] ? First : never;
type Tail<T extends any[]> = T extends [any, ...infer Rest] ? Rest : never;
type Last<T extends any[]> = T extends [...any[], infer L] ? L : never;
type H = Head<[1, 2, 3]>; // 1
type T = Tail<[1, 2, 3]>; // [2, 3]
type L = Last<[1, 2, 3]>; // 3
Distributive Conditional Types(ユニオン分配)
Conditional Typesにユニオン型を渡すと、各メンバーに対して個別に条件判定が適用される「分配(distribution)」が発生します。この動作はジェネリック型パラメータに対してのみ起こる点に注意が必要です。
type ToArray<T> = T extends any ? T[] : never;
type Result = ToArray<string | number>;
// string[] | number[] ← (string | number)[] ではない
分配が起こる仕組みを段階的に示すと次のようになります。
ToArray<string | number>
→ ToArray<string> | ToArray<number> // 各メンバーに個別適用
→ string[] | number[] // 各結果のユニオン
分配を起こさないようにする
ユニオン型を分配せず、まとめて1つの型として扱いたい場合は、型パラメータの両側をタプル([])で囲みます。
type ToArrayNoDist<T> = [T] extends [any] ? T[] : never;
type Result2 = ToArrayNoDist<string | number>;
// (string | number)[] ← 分配されない
分配を活用した型フィルタリング
分配の性質を利用すると、ユニオン型から特定の型だけを抽出・除外できます。組み込みのExtractとExcludeはまさにこの仕組みで動作しています。
// Extract の内部実装
type MyExtract<T, U> = T extends U ? T : never;
// Exclude の内部実装
type MyExclude<T, U> = T extends U ? never : T;
type StringOrNumber = "a" | "b" | 1 | 2;
type OnlyStrings = MyExtract<StringOrNumber, string>; // "a" | "b"
type OnlyNumbers = MyExclude<StringOrNumber, string>; // 1 | 2
neverはユニオン型の単位元として機能します。string | neverはstringに等しいため、条件にマッチしないメンバーはneverとなり自動的に消えます。
組み込みユーティリティ型の内部実装
TypeScriptが提供するユーティリティ型の多くはConditional Typesで実装されています。主要なものの内部実装を理解すると、自作の型ユーティリティを設計する際の参考になります。
| ユーティリティ型 | 内部実装 | 用途 |
|---|---|---|
Extract<T, U> | T extends U ? T : never | TからUに割り当て可能な型を抽出 |
Exclude<T, U> | T extends U ? never : T | TからUに割り当て可能な型を除外 |
NonNullable<T> | T & {} (TS 4.8以降) | nullとundefinedを除外 |
ReturnType<T> | T extends (...args: any) => infer R ? R : any | 関数の戻り値型を取得 |
Parameters<T> | T extends (...args: infer P) => any ? P : never | 関数の引数型をタプルで取得 |
ConstructorParameters<T> | T extends abstract new (...args: infer P) => any ? P : never | コンストラクタの引数型を取得 |
InstanceType<T> | T extends abstract new (...args: any) => infer R ? R : any | コンストラクタの戻り値(インスタンス)型を取得 |
Awaited<T> | 再帰的にPromiseをアンラップ | Promiseの解決値の型を取得 |
NonNullable<T>はTypeScript 4.8以降ではT & {}という交差型に変更されました。それ以前のバージョンではT extends null | undefined ? never : TというConditional Typesベースの実装でした。
実務で使えるConditional Typesパターン
プロパティの値に基づく型の切り替え
APIレスポンスのtypeフィールドに応じて、残りのフィールド構造が変わるケースに対応する型を定義できます。
type EventPayload<T extends string> =
T extends "click"
? { x: number; y: number; target: string }
: T extends "keydown"
? { key: string; code: string; ctrlKey: boolean }
: T extends "submit"
? { formData: Record<string, string> }
: Record<string, unknown>;
function handleEvent<T extends string>(
type: T,
payload: EventPayload<T>
) {
// payload の型が type に応じて絞り込まれる
}
handleEvent("click", { x: 10, y: 20, target: "button" }); // OK
handleEvent("keydown", { key: "Enter", code: "Enter", ctrlKey: false }); // OK
Reactコンポーネントの条件付きprops
あるpropの値によって、別のpropの要否が変わるパターンはReact開発で頻出します。Conditional Typesを使えば型レベルで制約を表現できます。
type ButtonProps<V extends "link" | "button" = "button"> =
V extends "link"
? {
variant: "link";
href: string;
onClick?: never; // link のときは onClick を禁止
}
: {
variant?: "button";
href?: never; // button のときは href を禁止
onClick: () => void;
};
function Button<V extends "link" | "button">(
props: ButtonProps<V>
) {
// ...
}
// 型安全な使い分け
<Button variant="link" href="/about" />; // OK
<Button variant="button" onClick={() => {}} />; // OK
<Button variant="link" onClick={() => {}} />; // コンパイルエラー
exhaustive check(網羅性チェック)
never型とConditional Typesを組み合わせることで、switch文やif-else分岐の網羅性をコンパイル時に保証できます。
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rect"; width: number; height: number }
| { kind: "triangle"; base: number; height: number };
function assertNever(x: never): never {
throw new Error(`Unexpected value: ${JSON.stringify(x)}`);
}
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rect":
return shape.width * shape.height;
case "triangle":
return (shape.base * shape.height) / 2;
default:
// Shape に新しいメンバーを追加し忘れるとここでコンパイルエラー
return assertNever(shape);
}
}
将来Shapeに新しいバリアントが追加された場合、対応するcaseを書かないとassertNeverの引数にnever以外の型が渡りコンパイルエラーになります。
オブジェクトのキーを型に基づいてフィルタリング
Conditional TypesとMapped Typesを組み合わせると、オブジェクト型から特定の型のプロパティだけを持つ部分型を構築できます。
type PickByValueType<T, ValueType> = {
[K in keyof T as T[K] extends ValueType ? K : never]: T[K];
};
interface User {
id: number;
name: string;
email: string;
age: number;
isActive: boolean;
}
type StringProps = PickByValueType<User, string>;
// { name: string; email: string }
type NumberProps = PickByValueType<User, number>;
// { id: number; age: number }
as句(Key Remapping)とConditional Typesの組み合わせにより、条件に合致しないキーはneverにマップされ、結果のオブジェクト型から除外されます。
高度なテクニック
再帰的Conditional Types
TypeScript 4.1以降、型エイリアスの再帰的参照が可能になりました。これにより、ネストしたオブジェクトの全プロパティをreadonlyにするDeepReadonly型などを定義できます。
type DeepReadonly<T> =
T extends Function
? T
: T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
interface Config {
db: {
host: string;
port: number;
credentials: {
user: string;
password: string;
};
};
features: string[];
}
type ReadonlyConfig = DeepReadonly<Config>;
// すべてのネストされたプロパティが readonly になる
ここではFunctionを最初にチェックして関数型はそのまま返し、次にobject(配列やオブジェクト)を再帰的に処理し、プリミティブ型はそのまま返しています。
Template Literal Typesとの組み合わせ
TypeScript 4.1で導入されたTemplate Literal Typesと組み合わせると、文字列パターンに基づく型操作が可能になります。
type EventName<T extends string> =
T extends `on${infer Rest}` ? Uncapitalize<Rest> : T;
type E1 = EventName<"onClick">; // "click"
type E2 = EventName<"onSubmit">; // "submit"
type E3 = EventName<"custom">; // "custom"
inferでテンプレートリテラルの一部を抽出し、組み込みのUncapitalizeで先頭文字を小文字に変換しています。
さらに実用的な例として、ドット区切りのパスからネストしたオブジェクトのプロパティ型を取得する型があります。
type PathValue<T, P extends string> =
P extends `${infer Key}.${infer Rest}`
? Key extends keyof T
? PathValue<T[Key], Rest>
: never
: P extends keyof T
? T[P]
: never;
interface AppState {
user: {
profile: {
name: string;
age: number;
};
settings: {
theme: "light" | "dark";
};
};
}
type Name = PathValue<AppState, "user.profile.name">; // string
type Theme = PathValue<AppState, "user.settings.theme">; // "light" | "dark"
複数条件のswitch風パターン
ネストしたConditional Typesを使うと、複数の条件を連鎖させたswitch文のような型定義が可能です。
type TypeName<T> =
T extends string ? "string" :
T extends number ? "number" :
T extends boolean ? "boolean" :
T extends undefined ? "undefined" :
T extends Function ? "function" :
"object";
type T1 = TypeName<string>; // "string"
type T2 = TypeName<() => void>; // "function"
type T3 = TypeName<string[]>; // "object"
条件が多くなる場合はインデントを揃えて記述すると読みやすくなります。
パフォーマンスとデバッグ
再帰の深さ制限
TypeScriptコンパイラには型の再帰深度に制限があります。TypeScript 4.5以降、末尾再帰(tail recursion)パターンであれば最大1000レベルまで展開されますが、それ以外のパターンでは100レベルでType instantiation is excessively deep and possibly infiniteエラーが発生します。
再帰的Conditional Typesを書く際のポイントは次のとおりです。
- 末尾再帰パターン(Conditional Typesの結果がそのまま再帰呼び出し)を使う
- 中間結果をタプルのアキュムレータに蓄積する
- ベースケース(再帰の終了条件)を必ず定義する
型が想定どおりに解決されない場合
Conditional Typesのデバッグでは、TypeScript Playground(typescriptlang.org/play)のホバー表示が有効です。型が展開されない場合の主な原因と対策を整理します。
| 現象 | 原因 | 対策 |
|---|---|---|
T extends ...が常にfalse分岐に入る | Tがジェネリクスのまま未解決 | 具体的な型を渡してテストする |
| 分配されてほしくないのに分配される | ユニオン型がジェネリック引数として渡されている | [T] extends [U]でラップする |
| 分配されてほしいのにされない | Conditional Typesの対象が直接の型パラメータでない | T自体をextendsの左辺にする |
Type instantiation is excessively deep | 再帰が深すぎる | 末尾再帰に書き換えるか、再帰回数を減らす |
Conditional Types・型ガード・オーバーロードの使い分け
型レベルの条件分岐には複数のアプローチがあり、場面に応じて適切な手法を選ぶ必要があります。
| 手法 | 適用タイミング | 利点 | 制限 |
|---|---|---|---|
| Conditional Types | 型定義・型エイリアスの中 | ジェネリクスと組み合わせて柔軟 | ランタイムには影響しない |
| 型ガード(type guard) | ランタイムの値による分岐 | 実行時の安全性も保証 | 型定義には使えない |
| 関数オーバーロード | 引数のパターンごとに戻り値型を変える | 呼び出し側の推論が明確 | 実装シグネチャがanyになりがち |
Conditional Typesは「型の世界」で完結する条件分岐であり、生成されるJavaScriptコードには一切影響しません。一方、型ガードはランタイムのif文やswitch文で値の型を絞り込むために使います。オーバーロードは引数のパターンが2〜3種類に限られる場合に有効ですが、パターンが増えるとConditional Typesの方が保守性に優れます。
まとめ
Conditional Typesは、TypeScriptの型システムを「型レベルのプログラミング言語」に引き上げる中核機能です。T extends U ? X : Yという構文を起点に、inferによる型抽出、ユニオン分配、再帰的な型操作など、幅広い応用が可能です。
ポイントを整理すると次のとおりです。
- 基本構文:
T extends U ? X : Yで型の割り当て可能性に基づく分岐を記述 - infer:
extendsのtrue分岐でパターンマッチにより型を抽出 - Distributive: ジェネリクスにユニオン型を渡すと各メンバーに分配される(回避するには
[T]でラップ) - 組み込みユーティリティ型:
Extract・Exclude・ReturnType・Awaited等の内部実装を理解すると応用力が高まる - 実務パターン: React propsの条件分岐、APIレスポンスの型安全な処理、exhaustive checkなど
TypeScript公式ドキュメントのConditional Typesページや、TypeScript Playgroundで実際にコードを書きながら動作を確認すると、理解がさらに深まります。
