【TypeScript】もう一歩先のExtendsとInferの使い方
どうもこんにちは、たくびーです。
TypeScriptの型システムのextendsやinferといった構文を理解するために、ジェネリック型を含めてどのような概念があるのかこのブログで解説していこうと思います。
TypeScriptの型システムを使った少し凝ったテクニック
早速ですが、いくつかのコード例を見ていきましょう。
少し凝ったテクニックですが、最初はあまり構えずに『こんな書き方もあるんだな』と気軽に眺めてみてください。
ネストした配列を再帰的にフラットにする
「T が配列なら、それを 1 段階フラットにする」「さらに要素が配列なら再帰的にフラットにする」という処理を、型レベルで実現してみます。
type Flatten<T> = T extends (infer U)[]
? Flatten<U>
: T;
type A1 = Flatten<string[]>; // string
type A2 = Flatten<number[][][]>; // number
type A3 = Flatten<boolean | number[]>;
// => boolean | number
Tが(infer U)[](何かの配列)なら、要素Uに対して再帰的にFlattenを適用しています。
条件付き型(conditional types)の中では再帰処理も書けるので型レベルでこのような処理も可能です。
Union型をIntersection型に変換する
「A | B | CのようなUnion型をA & B & CのようなIntersection型に変換する」というテクニックです。ライブラリの内部実装などで使われます。
type UnionToIntersection<U> =
(U extends any ? (k: U) => void : never) extends (k: infer I) => void
? I
: never;
ここで(U extends any ? (k: U) => void : never)のUがA | B | CのようなUnion型のとき、分配的条件型により(k: A) => void | (k: B) => void | (k: C) => voidという型になります。
次に先ほどの型に対して(k: infer I) => voidに割り当て可能かどうかの判定を行なっています。
少し複雑な話になりますが、TypeScriptでは複数の関数が|で並んでいる場合、1つの関数に割り当てるときにはIntersectionが発生します。
ざっくりと説明すると複数の関数を1つに「マージ」する際にはパラメータ型はすべての要素型を満たす必要があるためA & B & Cのようになるということです。
つまり、(k: A) => void | (k: B) => void | (k: C) => voidを(k: X) => void形式に強制すると、結果としてXはA & B & Cでなければすべてを満たせなくなります。
すると、最終的に型はAとBとCを含むすべての型 → A & B & Cになるというわけです。
型がUnionかどうか判定する
ここではコードを見てからそれぞれどのような処理をしているか見ていきましょう。
type IsUnion<T, U = T> =
T extends any
? [U] extends [T]
? false
: true
: never;
type X1 = IsUnion<string>; // false (単一型)
type X2 = IsUnion<string | number>; // true (Union型)
型引数として第1引数Tが「判定したい型」、第2引数UがデフォルトでTとなっています。
まず、T extends anyで分配的条件型で要素ごとに分解しています。(TがA | B | CならA,B,Cに分割する狙いがあります。)
その後、[U] extends [T]で型の比較を行なっています。
ここでUやTが[]で囲われていますが、これはタプルに包むと、分配が起こらずに「まとまり」として扱われるため分配を抑制することができます。
具体的に単一型のときとUnion型のときでどのような挙動になるか見ていきます。
T = stringの場合
-
T extends any→ 真 → 条件式に進む -
stringは分割できない -
[U] extends [T]つまり[string] extends [string]→ 真 →false - よって
IsUnion<string>はfalse
T = string | numberの場合
-
T extends any→ 真 → 分割して個別に判定(この場合、2要素:string,number) - それぞれの判定で
Uはデフォルト引数U = T→string | numberのまま - 最初の
stringの時:-
[U]は[string | number]でTは[string] -
[string | number] extends [string]→ 偽 →true
-
-
numberも同様にtrue - 分配的条件型でまとめると
true | trueとなる - よって
IsUnion<string | number>はtrue
このように分配的条件型を発生させないようにタプルで包むことで分配を抑制させて判定させることもできます。
少し凝ったテクニックを理解するためには
ここまでは、TypeScript ならではの「型を再帰的に扱うテクニック」や「UnionとIntersectionを変換するような高度な型操作」などを見てきました。これらは、TypeScriptの強力な型システムがあってこそ可能な芸当です。
ところで、こうしたテクニックの土台には、TypeScriptのジェネリック型(Generics)やextends、inferなどいくつかの仕組みがあります。
これらを次の章から詳しく見ていきたいと思います。
ジェネリック型(Generics)について
ジェネリック型は関数やクラスが取り扱う「型」そのものをパラメータとして扱える仕組みです。
例として以下のように<T>の形で「型を引数化」できます。
function echo<T>(value: T): T {
return value;
}
const str = echo<string>("Hello"); // 戻り値の型は string
const num = echo<number>(123); // 戻り値の型は number
このとおりTは「型の変数」のような役割を持っています。
呼び出し時に具体的な型を割り当てることで、型安全にやり取りできるようになります。
ここで例としてanyを使った場合を見ていきます。
function echoWithoutGeneric(value: any): any {
return value;
}
const result = echoWithoutGeneric(123);
// ここで result の型は any になってしまうため、実際のところ何の型なのか分からなくなります。
// 例えばうっかり文字列メソッドを呼んでも、コンパイル時にはエラーになりません。
result.toUpperCase();
// 実行時になって初めてエラーが起きる恐れがあります。
anyを使うと、どんな型の値でも受け取れますが、戻り値もanyになってしまい、「具体的にどんな型なのかが分からなくなる」という問題があります。
これでは、実行時エラーのリスクも大きくなり、型安全 (type safety) が損なわれます。
このコードをジェネリック型を使ったものに置き換えると下記のようなコードになります。
// 関数呼び出し時に <T> を推論し、T に合った型の値を返す
function echo<T>(value: T): T {
return value;
}
// 123 (number) を渡した場合、戻り値の型は number になる
const resultNum = echo(123);
resultNum.toUpperCase();
// ↑ ここはコンパイラがエラーを出してくれる (number型に toUpperCase はない)
// "Hello" (string) を渡した場合、戻り値の型は string になる
const resultStr = echo("Hello");
resultStr.toUpperCase();
// こちらは OK (string型に toUpperCase は存在する)
こうすることで、あらゆる型を引数に受け取りつつ、正確な型で戻り値を返せるようになります。
これによって、コンパイラが「文字列メソッドを呼ぶコードなのに number が来ていないか?」などをチェックしてくれます。
結果、実行時エラーのリスクを軽減でき、型安全にコーディングをすることができます。
また、ジェネリック型を使うことで1つの関数/クラスで複数の型を扱える柔軟性を手に入れることができます。
extendsについて - 型パラメータに対する制約
TypeScriptのextendsには以下の2つの異なる場面で使用されます。
- 型引数
- クラスの継承
今回は型引数に関して説明します。
ジェネリック型を使うとき「型Tが絶対に〇〇の構造を持っていないと困る!」という場面があると思います。
TypeScriptでは型パラメータに対してextendsで制約をかけることができます。
例として以下のコードを見てみましょう。
function sample<T extends string>(value: T) {
// T は必ず string 型だけになる
console.log(value.toUpperCase());
}
sample("Hello"); // OK
sample(123); // エラー: number は string を満たさない
この場合T extends stringとすることで、Tは文字列型または文字列と互換性のある型に限定されます。
また、オブジェクト構造への制約をかけることも可能です。
以下の例では、Tは{ id: number }の構造を必須としています。
function withId<T extends { id: number }>(obj: T) {
return obj.id;
}
withId({ id: 123, name: "Alice" }); // OK
withId({ name: "Bob" }); // エラー: id プロパティがない
このようにextendsはジェネリック型パラメータに対する上限(これは”Tは最低でもこの型を満たしている必要がある”というイメージ)や構造を定義するために使うことができます。
inferについて - 条件付き型内での部分的な型推論
TypeScriptには「条件付き型(conditional types)」というものがあります。
T extends U ? X : Y
このコードはTがUに割り当て可能な場合、Xになり、そうでない場合はYになります。
inferはこの構文の中だけで使え、「型の一部を推論して取り出す」役割を持ちます。
以下2つの例を見ながらどのような挙動をするのか見ていきましょう。
1つ目は関数の戻り値を推論する例です。
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
function getUser(id: number) {
return { id, name: "Alice" };
}
type User = MyReturnType<typeof getUser>;
// => { id: number; name: string }
こちらのコードではT extends (...args: any[]) => infer RはTが関数型なら、その戻り値の型をRとして推論するという挙動になります。
TypeScriptのユーティリティ型にあるReturnType<T>がこのような実装となっています。(もう少し厳密ですが...)
もう1つの例を見ていきましょう。
次は配列の要素を推論するというパターンです。
type ElementType<T> = T extends (infer U)[] ? U : T;
type A = ElementType<string[]>; // string
type B = ElementType<number>; // number (配列でないならそのまま)
Tが配列ならその要素をUとして推論します。(infer U)
(infer U)[]がマッチするときUは配列の要素型として推論されます。
配列型でなければTをそのまま使うというものです。
シンプルですが、こちらの例は分かりやすいですね。
このようにinferとは「型から型の一部を推論する」という挙動を行います。
繰り返しになりますが、inferは条件付き型(conditional types)の中で使われる型演算子で、extendsの右辺にのみ書くことができます。
分配的条件型(Distributive Conditional Types)
先ほど話題に出たようにTypeScriptには条件付き型(conditional types)があります。
そして、もしTがA | B | CのようなUnion型の場合は要素ごとに分解して判定し、結果をUnionで結合するという挙動が起きます。
type IsString<T> = T extends string ? true : false;
type foo = IsString<string | number>; // boolean
// この場合以下のような分解が起きています。
// string extends string ? true : false; → true
// number extends string ? true : false; → false
// type foo = true | false; → boolean
このテクニックを使ったものとして、TypeScriptのユーティリティ型にExtract<T, U>があります。
その定義は以下のようになっています。
type Extract<T, U> = T extends U ? T : never;
一見すると「単にTがUに割り当て可能ならTを返し、そうでなければneverを返す」だけ、というシンプルな型に見えます。
ですが、ここでTがUnion型(A | B | C)の場合に分配的条件型が発動すると、先ほどのように要素ごとに判定が行われます。
Extractの例で見てみましょう。
type MyUnion = string | number | boolean;
type SOrN = Extract<MyUnion, string | number>;
// => string | number (boolean は U に合わずフィルタされる)
MyUnionがstring | number | booleanとなっています。
この場合以下のように判定が行われています。
-
stringはstring | numberに割り当て可能 → 残る -
numberはstring | numberに割り当て可能 → 残る -
booleanはダメ →never
これらが最後に統合されるのでbooleanが除外される形になります。
ジェネリック型のTがUnion型の場合、分配的条件型(Distributive Conditional Types)が発生するということは覚えておきましょう。
まとめ
これらの概念はTypeScriptの型定義ファイルやユーティリティ型の実装を読んでいく際にとても役に立ってくれます。最初は理解が難しいかもしれませんが、実際に手を動かして「動いている様子」を確認したり、標準ライブラリの型定義を眺めているとだんだんと理解できるようになると思います。
ぜひ、上級テクニックも含めて楽しんでみてください。
今後の開発に活かしていただければ幸いです。
参考URL
Discussion