TypeScriptによる静的型付け言語入門③ 『型の絞り込み』
こんにちは! スマレジ テックファームのMichiです!
間が空きましたが、久しぶりにTypeScriptのキャッチアップに戻ります。今回は『TypeScriptの型の絞り込み機能』について学びます。
TypeScriptには、特定のコンテキストで変数の型の範囲を狭める、つまりより具体的な型に「絞り込む」(Type Narrowing)という機能があります。この機能を使いこなせば、より型安全なコードを書くことが可能になり、バグを防ぐのに非常に便利な手段となります。
本記事では、typeof
演算子、等価演算子、タグ付きユニオン、switch
文、ユーザー定義型ガードというキーワードを中心に、型絞り込みの基本的な手法を探っていきます。
1. typeof 演算子
typeof を使った絞りこみ
typeof
演算子はTypeScript特有の機能ではなく、元々JavaScriptにも存在する機能です。typeof 式
という形で、式の評価結果(型)を文字列として返します。
TypeScriptではこのtypeof
を使って型の絞り込みを行うことができます。
例えば、次のような関数があるとします。
function logInput(input: string | number) { if (typeof input === "string") { // string型に絞り込まれる console.log(input.toUpperCase()); } else { // number型に絞り込まれる console.log(input.toFixed(2)); } } logInput("Michi"); // 結果: MICHI logInput(30); // 結果: 30.00
この関数logInput
は、文字列または数値を引数に取ります。typeof
演算子を使ってinput
が文字列かどうかをチェックし、文字列であればそのまま処理を行います。一方、文字列でなければinput
は数値であると判定され、else
句の処理に移行します。
仮に絞り込みを行わずに、次のようなコードを書くとコンパイルエラーとなります。
function logInput2(input: string | number) { // 型の絞り込みをしない console.log(input.toUpperCase()); } // エラー: Property 'toUpperCase' does not exist on type 'string | number'. // Property 'toUpperCase' does not exist on type 'number'.
これは、toUpperCase
メソッドはstring
型にしか存在しないのにもかかわらず、input
の値がstring
型かnumber
型かを事前に判定できないためです。
typeof の結果
typeof
演算子の評価結果を以下の表にまとめました。
引数のデータ型 | typeof の結果 |
---|---|
文字列 | "string" |
数値 | "number" |
真偽値 | "boolean" |
BigInt |
"bigint" |
Symbol |
"symbol" |
null |
"object" |
undefined |
"undefined" |
関数オブジェクト | "function" |
その他のオブジェクト | "object" |
この中で注意しなければならないのは、null
とオブジェクトの挙動についてです。
まず、null
についてはobject
という判定がされます。これはJavaScriptの初期のバグがそのまま残っているためらしいのですが、非常に例外的な仕様となっているので気を付ける必要があります。
次にオブジェクトについてですが、関数かそれ以外かの2パターンしかありません。(JS/TSでは関数もオブジェクトの一種と考える)
そのため、配列や日付オブジェクト、正規表現なども全てobject
として評価されます。
2. 等価演算子
等価演算子(===
)も型絞り込みのために使うことができます。特に、null
やundefined
をチェックする際に有効です。
function printLength(input: string | null) { if (input === null) { console.log("input is null"); } else { // string型に絞り込まれる console.log(input.length); } } printLength(null) // 結果: input is null printLength("Michi") // 結果: 5
else
句のconsole.log(input.length);
はエラーとなりません。なぜなら、引数の型を見て、input
がnull
でない場合はstring
型であると、TypeScriptがちゃんと判定してくれるためです。
なお、当然ですが、下記のようなコードは書けません。
function printLength(input: string | null) { if (input === string) { console.log(input.length); } else { console.log("input is null"); } } // エラー: 'string' only refers to a type, but is being used as a value here.
等価演算子(===
)はあくまで値の比較に使用するためのものであり、型を直接比較することはできません。型の絞り込みはあくまで副次的な効果です。
3. タグ付きユニオン
これまでの方法では、ユーザーがオリジナルで定義したオブジェクトの型などを絞り込むことができませんでした。
そこで登場するのが、タグ付きユニオン(Tagged Union)です。これは、ユニオン型の各メンバが共通のリテラルプロパティ(タグ)を持つことで、特定の型がどのメンバに対応するかを識別するためのパターンです。
言葉で説明してもよくわからないので、例を見てみましょう。
type Circle = { type: "circle"; // タグ radius: number; }; type Square = { type: "square"; // タグ sideLength: number; }; type Shape = Circle | Square; function getArea(shape: Shape) { if (shape.type === "circle") { // Circle型に絞り込まれる return console.log(Math.PI * shape.radius ** 2); } else { // Square型に絞り込まれる return console.log(shape.sideLength ** 2); } } const circle: Circle = { type: "circle", radius: 3 }; const square: Square = { type: "square", sideLength: 3 }; getArea(circle); // 結果: 28.274333882308138 getArea(square); // 結果: 9
ユーザーで定義したオリジナルのCircle
型とSquare
型が存在しており、そのユニオン型としてShape
型が存在します。
各型はtype
というプロパティのタグを持つことで、それぞれがどの型に属するのかの識別子となります。このタグは文字列のリテラル型で、型宣言で指定した文字列以外のリテラルが入ることはありません。
このタグを使用して、if (shape.type === "circle")
のように型を絞り込むことができます。
上の例では、Circle
型かSquare
型かによって計算ロジックを分岐させ、それぞれの図形の面積を求めています。
4. switch文を使った絞り込み
これまでに紹介したコードはすべて、 switch
文に置き換えることが可能です。
試しに、タグ付きユニオンのコードをswitch
文で置き換えてみます。
type Circle = { type: "circle"; // タグ radius: number; }; type Square = { type: "square"; // タグ sideLength: number; }; type Shape = Circle | Square; function getArea(shape: Shape) { switch (shape.type) { case "circle": // Circle型に絞り込まれる return console.log(Math.PI * shape.radius ** 2); case "square": // Square型に絞り込まれる return console.log(shape.sideLength ** 2); } } const circle: Circle = { type: "circle", radius: 3 }; const square: Square = { type: "square", sideLength: 3 }; getArea(circle); // 結果: 28.274333882308138 getArea(square); // 結果: 9
switch
文を使うメリットは次の通りです。
- 各ケースが明示的に列挙されるため、可読性が向上する。
switch
文がすべてのケースを網羅しているかを TypeScriptがチェックしてくれる。これにより、新たなタイプが追加されたときに、すべてのケースを正しく処理しているかを保証できる。
特に、上記のようなタグ付きユニオンでの絞り込みを行う場合は、switch
文を使うほうが一般的なようです。
5. ユーザー定義型ガード
最後に、ユーザー定義型ガード(User-Defined Type Guards)について見てみましょう。
まず、ユーザー定義型ガードが必要な場面について考えてみます。先ほどから例に出している図形の面積を計算するプログラムで、Circle
型かSquare
型かを判定する部分を別の関数に切り出します。
type Circle = { type: "circle"; // タグ radius: number; }; type Square = { type: "square"; // タグ sideLength: number; }; type Shape = Circle | Square; // 引数shapeがCircle型ならtrue、それ以外ならfalseを返す function isCircle(shape: Shape) { return shape.type === "circle"; }
この関数isCircle
を使って型の絞り込みを行おうとすると、コンパイルエラーが発生します。
function getArea(shape: Shape) { if (isCircle(shape)) { // エラー: Property 'radius' does not exist on type 'Shape'. // Property 'radius' does not exist on type 'Square'. return Math.PI * shape.radius ** 2; } else { // エラー: Property 'sideLength' does not exist on type 'Shape'. // Property 'sideLength' does not exist on type 'Circle'. return shape.sideLength ** 2; } }
このように、条件分岐の条件で関数呼び出しが行われた場合、TypeScriptはその関数の定義まで見に行って型の絞り込みを行ってくれるわけではありません。このような場面で、ユーザー定義型ガードが役立ちます。
ユーザー定義型ガードは、返り値の型を記述する箇所に通常の型ではなく、型述語(type predicates)という形式で記述します。型述語の書き方は引数名 is 型
という形式です。
実際のコードを見てみましょう。
// 引数shapeがCircle型ならtrue、それ以外ならfalseを返す function isCircle(shape: Shape): shape is Circle { return shape.type === "circle"; } function getArea(shape: Shape) { // コンパイルエラーとならない if (isCircle(shape)) { return Math.PI * shape.radius ** 2; } else { return shape.sideLength ** 2; } } const circle: Circle = { type: "circle", radius: 3 }; const square: Square = { type: "square", sideLength: 3 }; getArea(circle); // 結果: 28.274333882308138 getArea(square); // 結果: 9
isCircle
関数は、shape
がCircle
型であることを確認する型ガードとなります。shape is Circle
の部分が型述語です。
この関数の返り値がtrue
である場合、引数shape
はCircle
型であると保証できます。ゆえに、関数getArea
内で型の絞り込みが行われ、shape
のradius
、およびsideLength
プロパティに安全にアクセスすることができます。
まとめ
以上、TypeScriptの『型の絞り込み』についてのキャッチアップでした。
このあたりの機能を使いこなせると、TypeScriptを書いているという実感が湧いてきますね。