TypeScript上級講座 #3 Conditional typesとinfer

#1 keyofとtypeof#2 Mapped typesで型を加工する二つの道具を見ました。今回はその上に分岐を加える道具 — conditional typesinferです。この二つが入ると、組み込みユーティリティ型のほぼすべてを自分で書けるようになります。

基本 — T extends U ? X : Y #

文法はJavaScriptの三項演算子と同じ形をしています。

conditional type基本
type IsString<T> = T extends string ? true : false;

type A = IsString<'hello'>;  // true
type B = IsString<42>;       // false
type C = IsString<boolean>;  // false

型レベルのif文と見ればよいでしょう。Tstringの部分集合ならtrue、そうでなければfalse

extendsの意味が少し紛らわしいかもしれませんが、ここでのA extends Bは「AはBに代入可能か」です。クラス継承のextendsではなく部分集合チェックです。

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型に分配されます。

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のようなユーティリティが単純な一行で定義されます。

Exclude直接実装
type MyExclude<T, U> = T extends U ? never : T;

type WithoutString = MyExclude<string | number | boolean, string>;
// number | boolean

Tがunionなので分配され、各メンバーをチェックします。stringstring extends string ? never : stringnevernumber/booleanはそのまま残ります。結果をunionで合わせるとstringが抜けた形です。

neverはunionの中で痕跡が消えるという点が要点です。string | never | numberは実際にはstring | numberに絞られます。

同じ方式でExtractも作られます。

Extract直接実装
type MyExtract<T, U> = T extends U ? T : never;

type OnlyString = MyExtract<string | number | boolean, string>;
// string

前の記事でちらっと言及したOmitExcludeが登場した理由が今見えるはずです — キー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 — 組み込みだが一行 #

nullundefinedをunionから除く組み込み。

NonNullable
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 — 型の中で変数を宣言する #

inferconditionalの中でだけ使える特別なキーワードです。「この位置の型を変数のように捉えておけ」という意味です。

infer基本形
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を自分で作る #

最もよく使う組み込みの一つ。関数型から戻り値の型だけを引き出す。

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を自分で作る #

同じ方式で引数の型を引き出すこともできます。ただし今回は単一の型ではなくタプルです。

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())のようなコードの結果型を扱うときによく出会うケース。

Awaited直接実装 (簡単版)
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に制約をかけることもできます。

infer extends制約
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として捉えます。型レベルのパターンマッチングと呼び始められるところです。

実戦 — 関数チェーンの最後の戻り値の型 #

少し難しい例を一つ。複数の関数をチェーンで呼んだ後、最後の関数の戻り値の型を引き出す。

LastReturn — 再帰 + infer
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 — 一緒に使うと表現力が爆発 #

#2DeepReadonlyをちらっと見ました。conditionalが入ると各フィールドをチェックして異なる処理をすることが可能になります。

値が関数のフィールドだけreadonlyに
type LockMethods<T> = {
  [K in keyof T]: T[K] extends Function ? Readonly<T[K]> : T[K];
};

または任意propだけ?を取る変換。

optionalだけ集めてrequiredに
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のような組み込みヘルパーを扱います。型でルートパターンをモデリングしたり、イベントハンドラ名を自動生成する場面で使われます。

X