👏

TypeScript の switch 文で網羅性をチェックするテクニック

に公開

TypeScript で switch 文を記述する際に default で何を return するべきか悩むことはありませんか。ユニオン型を switch 文で処理する場合、すべてのケースが処理されているかを確認する「網羅性チェック」というテクニックがあります。この記事では、never 型を活用した手法を詳しく解説します!

網羅性チェックとは

網羅性チェックとは、ユニオン型の取りうるすべての値が適切に処理されていることを、TypeScript のコンパイラに確認させる技術です。新しい型が追加された際に、処理の漏れを防ぐことができます。

基本的な型定義

ユーザー権限管理システムを例に、基本的な型を定義します

interface AdminUser {
  type: "admin";
  userId: string;
  permissions: string[];
}

interface RegularUser {
  type: "user";
  userId: string;
  subscriptionLevel: "free" | "premium";
}

type User = AdminUser | RegularUser;

never 型を使った網羅性チェック

TypeScript の never 型は「決して到達しない」ことを表現する型です。すべてのケースが処理されている場合、default 節の値は never 型となります。

基本的な網羅性チェック

function processUser(user: User) {
  switch (user.type) {
    case "admin":
      break;
    case "user":
      break;
    default:
      user; // user は never 型となる
  }
}

なぜ user が never 型になるのか

ここで重要なのは、TypeScript の型推論の仕組みです。

  1. 関数開始時点: userUser 型(AdminUser | RegularUser
  2. case "admin" を通過後: userRegularUser 型に絞り込まれる
  3. case "user" を通過後: user は何の型でもない状態になる

TypeScript は各 case 文を通過するたびに、残りの可能性を除外していきます。すべての可能性が除外された結果、default 節に到達する時点では「どの型でもない」状態、つまり never 型になります。

これは TypeScript が「このコードは実行されることがない」と判断していることを意味します。

新しい型を追加した場合

新しい GuestUser 型を追加してみましょう。

interface GuestUser {
  type: "guest";
  sessionId: string;
  expiresAt: Date;
}

type User = AdminUser | RegularUser | GuestUser;

この時点で、既存の processUser 関数は GuestUser を処理していないため、default 節の userGuestUser 型となり、網羅性が不完全であることがわかります。

function processUser(user: User) {
  switch (user.type) {
    case "admin":
      break;
    case "user":
      break;
    default:
      user; // user は GuestUser 型(never 型ではない!)
  }
}

型推論の流れ

  1. 関数開始時点: userUser 型(AdminUser | RegularUser | GuestUser
  2. case "admin" を通過後: userRegularUser | GuestUser
  3. case "user" を通過後: userGuestUser
  4. default 節: userGuestUser 型のまま

つまり、処理されていない型が残っているため、never 型にならずに具体的な型(GuestUser)が残ります。これが網羅性チェックの仕組みです。

assertUnreachable 関数を使った実用的な方法

この問題を解消するアプローチとして、assertUnreachable 関数を使用します。

function assertUnreachable(value: never): never {
  throw new Error(`Missed a case! ${value}`);
}

function getUserDashboard(user: User): string {
  switch (user.type) {
    case "admin":
      return "管理者ダッシュボード";
    case "user":
      return user.subscriptionLevel === "premium"
        ? "プレミアムダッシュボード"
        : "通常ダッシュボード";
    default:
      assertUnreachable(user);
    // Error: type 'GuestUser' is not assignable to parameter of type 'never'
  }
}

これによって、user は never ではないので、GuestUser の未定義に気づくことができます。

GuestUser 型の処理を追加すると、エラーが解消されます

function getUserDashboard(user: User): string {
  switch (user.type) {
    case "admin":
      return "管理者ダッシュボード";
    case "user":
      return user.subscriptionLevel === "premium"
        ? "プレミアムダッシュボード"
        : "通常ダッシュボード";
    case "guest":
      return "ゲストダッシュボード";
    default:
      assertUnreachable(user); // OK
  }
}

戻り値の型アノテーションを使った網羅性チェック

関数の戻り値型を明示的に指定することで、すべてのパスで値が返されているかをチェックできます

function getUserPermissionLevel(user: User): number {
  // Function lacks ending return statement and return type does not include 'undefined'.
  switch (user.type) {
    case "admin":
      return 100;
    case "user":
      return user.subscriptionLevel === "premium" ? 50 : 10;
    // GuestUser の処理が漏れている場合、コンパイルエラーになる
  }
}

以下のように guest のケースを追加することでエラーを解消できます。

function getUserPermissionLevel(user: User): number {
  switch (user.type) {
    case "admin":
      return 100;
    case "user":
      return user.subscriptionLevel === "premium" ? 50 : 10;
    case "guest":
      return 1;
    default:
      return assertUnreachable(user);
  }
}

テンプレートリテラル型を使った複数値の組み合わせ

複数の値の組み合わせを網羅的にチェックしたい場合は、テンプレートリテラル型を活用できます

type NotificationStatus = "read" | "unread" | "archived";
type Priority = "high" | "medium" | "low";

function handleNotification(status: NotificationStatus, priority: Priority) {
  const combination =
    `${status}-${priority}` as `${NotificationStatus}-${Priority}`;

  switch (combination) {
    case "unread-high":
      console.log("緊急通知を表示");
      break;
    case "unread-medium":
    case "unread-low":
      console.log("通常通知を表示");
      break;
    case "read-high":
    case "read-medium":
    case "read-low":
      console.log("既読通知を非表示");
      break;
    case "archived-high":
    case "archived-medium":
    case "archived-low":
      console.log("アーカイブ済み通知を処理");
      break;
    default:
      assertUnreachable(combination);
  }
}

まとめ

TypeScript の網羅性チェックは、型安全なコードを書く上で重要なテクニックです。主要なポイントは以下の通りです。

  • never 型の活用: すべてのケースが処理されているかを確認
  • assertUnreachable 関数: 実用的で分かりやすい方法
  • 戻り値の型アノテーション: 関数の完全性を保証
  • テンプレートリテラル型: 複数値の組み合わせを網羅的にチェック

これらの技術を適切に使用することで、リファクタリング時の見落としを防ぎ、より堅牢な TypeScript コードを書くことができます。

Discussion