この記事は、弁護士ドットコム株式会社の Advent Calendar 2023 の 22 日目の記事です。
前日は @et_tei さんの「FireHOL で公開されているブラックリストからの接続 Akamai でブロックする」でした。
こんにちは。税理士ドットコム事業部の @komtaki です。
数年間 TypeScript を業務で使っていたのですが、型パズルの Type Challenges をやってようやく TypeScript の本質を理解していないことに気づきました。
この記事では Type Challenges を通して、私のような型パズルが難しいと感じる人のために型パズルを理解するための言語機能と使い方を 8 つのポイントでまとめます。
この 8 つのポイントを理解すれば、いろんな型パズルが理解できるようになるはずです。
Type Challenges とは TypeScript の型システムに関する問題集を提供する OSS で、型システムの理解を深められます。
なおこの記事の型パズルとは TypeScript の型のみを使い複雑な機能や構造を表現する手法と定義します。そして読者はすでに基本的な Generics や readonly、リテラル型、ユニオン型、primitive 型は理解している前提とします。
各ポイントの最後に、できる限り対応しそうな Type Challenges の Easy の問題を紹介しています。理解したと思ったら、最後に挑戦してみてください。
8つのポイント
1. extendsによるnarrowing
extends はクラスやインタフェースを継承するためのキーワードです。多くの言語であるように、extends で既存のクラスやインタフェースを拡張できます。
interface Animal { move: () => void } interface Dog extends Animal { bark: () => void }
しかしここでは、extends
を利用して型を絞り込む(narrowing
)ことにフォーカスします。
TypeScript におけるnarrowing
は、コンパイラが特定の条件で型を絞り込む(より具体的な型に制約する)仕組みのことです。条件分岐や特定の操作によって、変数や値の型をより具体的な型に絞り込みできます。
またジェネリックスと併用して、汎用的な型や関数を作成するため特定の型を決め打ちせず、柔軟性を持たせられます。
もちろんnarrowing
はtypeof
やinstanceof
、in
演算子でもできますが、ここでは型パズルでつかう extends を取り上げます。
下記の例では、Generics の引数T
はstring or number
であるという制約を表現しています。
type Form<T extends string | number> = { value: T } type TextForm = Form<string> // ok type InvalidForm = Form<boolean> // ts-error
これだけだと基本的な内容ですが、Conditional Types と組み合わせることで本当の力を発揮します。次からが本番です。
これを理解するだけで、Type Challenges の Concat や Push が解けるので挑戦してみましょう。モダンな JS を書いていれば、extends を使って雰囲気で解けるはずです。
2. Conditional Types(条件付き型)
Conditional Types(条件付き型)は、条件に応じて型を選択するための機能です。この機能で、TypeScript は条件ごとに異なる型を推論できます。
残念ながら型なので=などの演算子は使えません。多くの場合T extends U ? X : Y
のような形式で表し、T
が型U
を満たす場合はX
型を、満たさない場合はY
型を表現します。
例えば、次のように使います。
type Check<T> = T extends string ? boolean : number; type Value = Check<string>; // boolean type NumberValue = Check<number>; // number
Value は Generics の引数T
がstring
で、当然string
はstring
と同じ型(extends している)なので boolean になります。一方 NumberValue は Generics の引数T
がnumber
で、number
はstring
を extends していないため number になります。
これを理解すれば、Type Challenges の If が解けるので挑戦してみましょう。
3. Distributive Conditional Types(ユニオン型の分配法則)
Distributive Conditional Types(ユニオン型の分配法則)は、Conditional Types がジェネリック型に使われる時、ユニオン型の各メンバーに対して条件が適用されることです。
type Check<T> = T extends string ? boolean : number; type Result = Check<string | number>; // (string extends string ? boolean : number) | (number extends string ? boolean : number) // (boolean) | (number)
この例では、Check
という条件型を使ってstring | number
に適用します。Check<string | number>
が評価される際、string
とnumber
のそれぞれに条件型が適用され、その結果がユニオン型になります。
ここで重要なのは、条件付き型がジェネリック型に適用された場合、T
がユニオン型の場合、その各要素に対して条件が個別に判定されること。これが分配法則の本質です。
4. never
never
は絶対に到達不可能なコードを示す場合や、関数が例外を投げて戻り値を返さない場合に使います。
似た型として void がありますが、大きな違いがあります。
void
型は、何も返さないことを示すために使われますが、関数は正常に終了します。never
型は、絶対に値を返さない関数や、絶対に終了しない関数(無限ループや例外投げる関数)を表します。
型パズルにおいて、never
は Conditional Types と併用し条件に合致した場合、型を返さないというケースで使います。
type ExcludeFalsy<T> = T extends false | null | undefined ? never : T; type NonFalsyString = ExcludeFalsy<string | null | undefined | false>;
この例では、ExcludeFalsy
型はT
がfalse
、null
、undefined
のいずれかである場合はnever
型、それ以外の場合はT
そのものの型を返します。
NonFalsyString
はstring | null | undefined | false
という型を持っていますが、ExcludeFalsy
を使うことでfalse
、null
、undefined
を除外し、結果としてstring | never
型のみが残ります。
そしてnever型にはどんな値もセットできないため、 string | never
は string
になります。
これを理解すれば、Type Challenges の Exclude が解けるので挑戦してみましょう。
ヒント:分配法則を使います。
5. infer
infer
は、Conditional Types 内で型変数を抽出するために使うキーワードです。
type ExtractType<T> = T extends Array<infer U> ? U : never; type ArrayElementType = ExtractType<string[]>; // string
この例では、ExtractType
という条件付き型を定義しています。この型は、T
が配列型である場合、その配列の要素型をinfer U
を通じて抽出し、U
を返します。そして、ArrayElementType
ではExtractType
を使ってstring[]
の要素型であるstring
を抽出しています。
これを理解すれば、Type Challenges の First が解けるので挑戦してみましょう。
6. key of
keyof
は、オブジェクトのキー(プロパティ名)を取得するキーワードです。keyof
を使うと、オブジェクトの型からプロパティ名を取り出し、それらをユニオン型として表現できます。
type Person = { name: string; age: number; email: string; }; type PersonKeys = keyof Person; // "name" | "age" | "email"
7. Map types
マップ型(Map Types)は、既存の型を操作し新しい型を生成する機能で、既存の型の各プロパティを変更、追加、削除できます。
マップ型は、{ [K in Keys]: ValueType }
のような形式で表現します。
type Person = { name: string; age: number; }; // すべてのプロパティをオプショナルにする type PartialPerson = { [K in keyof Person]?: Person[K]; }; const partialData: PartialPerson = {}; // { name?: string; age?: number; }
この例では、PartialPerson
というマップ型を使い、Person
型の各プロパティをオプショナルにしています。[K in keyof Person]?: Person[K]
の部分は、Person
型の各プロパティをループし、それぞれのプロパティをオプショナルに変更して新しい型を作っています。
これを理解すれば、Type Challenges の pick が解けるので挑戦してみましょう。
8. 再帰処理
なんと再帰処理も型でできます。TypeScript における型の再帰処理は、型定義内で自己参照し、複雑なデータ構造やパターンの表現を可能にします。
これにより、木構造やリスト、グラフなどの再帰的なデータ構造を表現し、型を柔軟に操作できます。
type TreeNode<T> = { value: T; left?: TreeNode<T>; right?: TreeNode<T>; }; // 数値を持つTreeNode型の宣言 const numericNode: TreeNode<number> = { value: 10, left: { value: 5, left: { value: 3, }, right: { value: 8, }, }, right: { value: 15, }, };
これを理解すれば、Type Challenges の Awaited が解けるので挑戦してみましょう。
まとめ
いかがだったでしょうか。TypeScript の深淵を少しのぞけたのではないでしょうか。
正直、このレベルの知識を普段の業務で使う機会は少ないですが、ちょっとしたライブラリを作るときとか凝ったことしたいときにはきっと役に立つはずです。
ぜひ Type Challenges で紹介できなかった他の問題にも挑戦してみてください。easy の中には、難易度を疑う問題もあるので、ご注意を。
私もまだ medium に挑戦している途中なので、一緒に頑張りましょう。
明日の弁護士ドットコム株式会社の Advent Calendar 2023 の担当は クラウドサイン事業本部の篠田(@tttttt_621_s) さんの「Axios における request method の引数の型定義を調査した話」です。お楽しみに。