Michi's Tech Blog

一人前のWebエンジニアを目指して

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. 等価演算子

等価演算子===も型絞り込みのために使うことができます。特に、nullundefinedをチェックする際に有効です。

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);はエラーとなりません。なぜなら、引数の型を見て、inputnullでない場合は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関数は、shapeCircle型であることを確認する型ガードとなります。shape is Circleの部分が型述語です。

この関数の返り値がtrueである場合、引数shapeCircle型であると保証できます。ゆえに、関数getArea内で型の絞り込みが行われ、shaperadius、およびsideLengthプロパティに安全にアクセスすることができます。

まとめ

以上、TypeScriptの『型の絞り込み』についてのキャッチアップでした。

このあたりの機能を使いこなせると、TypeScriptを書いているという実感が湧いてきますね。