TypeScript上級講座 #3 Conditional typesとinfer
#1 keyofとtypeofと#2 Mapped typesで型を加工する二つの道具を見ました。今回はその上に分岐を加える道具 — conditional typesとinferです。この二つが入ると、組み込みユーティリティ型のほぼすべてを自分で書けるようになります。
基本 — T extends U ? X : Y
#
文法はJavaScriptの三項演算子と同じ形をしています。
type IsString<T> = T extends string ? true : false;
type A = IsString<'hello'>; // true
type B = IsString<42>; // false
type C = IsString<boolean>; // false
型レベルのif文と見ればよいでしょう。Tがstringの部分集合ならtrue、そうでなければfalse。
extendsの意味が少し紛らわしいかもしれませんが、ここでのA extends Bは「AはBに代入可能か」です。クラス継承のextendsではなく部分集合チェックです。
type T1 = 'hello' extends string ? true : false; // true
type T2 = string extends 'hello' ? true : false; // false
type T3 = 'red' extends 'red' | 'blue' ? true : false; // true
type T4 = 'red' | 'blue' extends 'red' ? true : false; // false (なぜ? — まもなく説明)
T4が意外ですが、これは次の節で説明します。
分配条件付き (distributive conditional types) #
TypeScriptのconditional typeには一つ特異な動作があります — union型に分配されます。
type Naked<T> = T extends string ? 'yes' : 'no';
type Result = Naked<'hello' | 42 | true>;
// 'yes' | 'no' | 'no'
// = 'yes' | 'no'
'hello' | 42 | trueが丸ごと評価されるのではなく、各メンバーに対して別々に評価されます。その結果が再びunionで合わされます。これを分配条件付きと呼びます。
この動作のおかげで、Excludeのようなユーティリティが単純な一行で定義されます。
type MyExclude<T, U> = T extends U ? never : T;
type WithoutString = MyExclude<string | number | boolean, string>;
// number | boolean
Tがunionなので分配され、各メンバーをチェックします。stringはstring extends string ? never : string → never。number/booleanはそのまま残ります。結果をunionで合わせるとstringが抜けた形です。
neverはunionの中で痕跡が消えるという点が要点です。string | never | numberは実際にはstring | numberに絞られます。
同じ方式でExtractも作られます。
type MyExtract<T, U> = T extends U ? T : never;
type OnlyString = MyExtract<string | number | boolean, string>;
// string
前の記事でちらっと言及したOmitにExcludeが登場した理由が今見えるはずです — キーunionから引き算するキーを取り出すとき分配条件付きが必要でした。
分配を止めたいとき — [T] extends [U]
#
時々はunionを丸ごと一度に評価したいです。型をタプルで包むと分配が止まります。
type IsExactlyString<T> = [T] extends [string] ? true : false;
type X = IsExactlyString<string | number>; // false (一度に評価)
type Y = IsExactlyString<string>; // true
上の分配バージョンはunionメンバーそれぞれについて評価されるので、結果がtrue | falseつまりbooleanに混ざります。タプルで包むとそれが止まります。「このunionが正確にstringの部分集合か」をチェックするときによく使う慣用句です。
NonNullable — 組み込みだが一行
#
nullとundefinedをunionから除く組み込み。
type MyNonNullable<T> = T extends null | undefined ? never : T;
type X = MyNonNullable<string | null | undefined>; // string
type Y = MyNonNullable<number | undefined>; // number
分配 + neverパターンのもう一つの応用。ユーティリティ型がだんだん馴染んでくると、「これはどう作られたんだ?」という質問に5秒以内に答えが出るようになります。
infer — 型の中で変数を宣言する
#
inferはconditionalの中でだけ使える特別なキーワードです。「この位置の型を変数のように捉えておけ」という意味です。
type ElementType<T> = T extends (infer U)[] ? U : never;
type A = ElementType<string[]>; // string
type B = ElementType<number[]>; // number
type C = ElementType<boolean>; // never (配列ではない)
T extends (infer U)[]は「Tがある何かのUの配列か? なら、そのUを捉えておけ」という意味。Tが配列ならその要素型を、そうでなければnever。
この(infer X)パターンが型の中である種のマッチングを作り出します。正規表現のキャプチャグループのような役割です。
ReturnTypeを自分で作る
#
最もよく使う組み込みの一つ。関数型から戻り値の型だけを引き出す。
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
function getUser() {
return { id: 'u1', name: 'カーティス' };
}
type User = MyReturnType<typeof getUser>;
// { id: string; name: string }
(...args: any[]) => infer R — 「どんな引数でも受け取ってRを返す関数なら、そのRを捉えてくれ」。Tが関数ならその戻り値の型がRに捉えられます。組み込み定義が事実上この一行です。
Parametersを自分で作る
#
同じ方式で引数の型を引き出すこともできます。ただし今回は単一の型ではなくタプルです。
type MyParameters<T> = T extends (...args: infer P) => any ? P : never;
function greet(name: string, age: number) {
return `${name} is ${age}`;
}
type Args = MyParameters<typeof greet>;
// [name: string, age: number]
...args: infer P — 「rest仮引数を丸ごとPに捉えてくれ」。関数が受け取るすべての引数をタプルとして引き連れてきます。
これがなぜ便利かというと — 他の関数に同じ引数を受け取らせたいときに毎回書かなくて済みます。
function logCall<F extends (...args: any[]) => any>(
fn: F,
...args: Parameters<F>
): ReturnType<F> {
console.log('呼び出し:', fn.name, args);
return fn(...args);
}
logCall(greet, 'カーティス', 30); // OK、戻り値はstring
logCall(greet, 30, 'カーティス'); // ✗ 引数の順序が間違い
三行のwrapper関数に元の関数のシグネチャがそのまま保存されます。引数のチェックも、戻り値の型もすべて正確に動作します。
Awaited — Promiseを解く
#
fetch().then(r => r.json())のようなコードの結果型を扱うときによく出会うケース。
type MyAwaited<T> = T extends Promise<infer U> ? U : T;
type A = MyAwaited<Promise<string>>; // string
type B = MyAwaited<string>; // string (Promiseでなければそのまま)
type C = MyAwaited<Promise<Promise<number>>>; // Promise<number> ← これは一度だけ解ける
Cが意外ですが、一度だけ解く単純版なので二重Promiseは全部剥がれません。組み込みのAwaitedは再帰的に解きます。
type MyAwaited<T> = T extends Promise<infer U> ? MyAwaited<U> : T;
type C = MyAwaited<Promise<Promise<number>>>; // number
MyAwaited<U>を自分自身で呼び出します。TがPromiseでなくなるまで解き続けます。組み込みAwaitedの実際の定義はthenableオブジェクトまで扱うので少し複雑ですが、核心のアイデアは同じです。
infer + extends制約
#
inferに制約をかけることもできます。
type FirstString<T> = T extends [infer U extends string, ...any[]]
? U
: never;
type A = FirstString<['hello', 42, true]>; // 'hello'
type B = FirstString<[42, 'hello']>; // never (最初の要素がstringではない)
TypeScript 4.7から可能になった文法です。タプルの最初の要素がstringのときだけ、その位置をUとして捉えます。型レベルのパターンマッチングと呼び始められるところです。
実戦 — 関数チェーンの最後の戻り値の型 #
少し難しい例を一つ。複数の関数をチェーンで呼んだ後、最後の関数の戻り値の型を引き出す。
type LastReturn<T extends ((...args: any[]) => any)[]> =
T extends [...any[], infer Last extends (...args: any[]) => any]
? ReturnType<Last>
: never;
const fns = [
(x: number) => x * 2,
(x: number) => x + 1,
(x: number) => `result: ${x}`,
] as const;
type Final = LastReturn<typeof fns>; // string
タプルの最後の要素をLastとして捉え、そのReturnTypeを引き出します。[...any[], infer Last]が要点 — 「前に何があってもよく、最後を捉えろ」。このようなパターンがライブラリの型を書くときに時々登場します。
conditional + mapped — 一緒に使うと表現力が爆発 #
#2でDeepReadonlyをちらっと見ました。conditionalが入ると各フィールドをチェックして異なる処理をすることが可能になります。
type LockMethods<T> = {
[K in keyof T]: T[K] extends Function ? Readonly<T[K]> : T[K];
};または任意propだけ?を取る変換。
type RequireOptional<T> = {
[K in keyof T as undefined extends T[K] ? K : never]-?: T[K];
};
type Form = { id: string; nickname?: string; age?: number };
type Filled = RequireOptional<Form>;
// { nickname: string; age: number }
as節でconditionalを使ってキーをふるい分け、modifierでoptionalを剥がします。道具が集まると表現が自然になります。
落とし穴 — extendsは分配がデフォルト、意識しなければならない
#
分配動作を忘れると結果が予想と違うことがあります。
type IsArray<T> = T extends any[] ? true : false;
type A = IsArray<string[] | number>;
// boolean (= true | false)
unionメンバーそれぞれが別々に評価されてtrue | falseになりました。意図が「全体が一度に評価」だったならタプルで包むべきです。
type IsArrayStrict<T> = [T] extends [any[]] ? true : false;
type A = IsArrayStrict<string[] | number>; // false
分配が必要かどうか — 毎回「このconditionalはunionメンバーに分配されるべきか?」を意識しなければなりません。分配がデフォルト動作で、止めるのは明示的でなければならないと覚えておいてください。
まとめ #
今回整理した内容:
- conditional type —
T extends U ? X : Y - 分配条件付き — unionが自動的に分配。
Exclude/Extractの動作原理 - 分配を止める —
[T] extends [U] NonNullableも分配 +neverの一行infer— conditionalの中で型変数のように捉えておくReturnType<T>、Parameters<T>、Awaited<T>はすべて一行のconditional + infer[...any[], infer Last]のようなタプルパターンマッチング- conditional + mapped組み合わせでキー別に異なる変換
次の記事(#4 Template literal types)では文字列型を合成する道具 — `${...}`パターンとCapitalize/Uppercaseのような組み込みヘルパーを扱います。型でルートパターンをモデリングしたり、イベントハンドラ名を自動生成する場面で使われます。