0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

TypeScriptで判別可能ユニオンを活用する

Posted at

1. 判別可能ユニオンとは?

判別可能ユニオン とは、複数の異なる構造を持つ型をまとめて扱うためのパターンで、それぞれの型に共通の「判別子」プロパティを持たせることで、どの型かを一意に識別できるようにしたものです。

この設計により、条件分岐による安全な型の絞り込み(ナローイング) が可能となり、型安全なコードを簡潔に記述できます。

例:通知メッセージを扱う型

type InfoNotification = {
  type: "info";
  message: string;
};

type WarningNotification = {
  type: "warning";
  message: string;
  retryAfterSeconds: number;
};

type ErrorNotification = {
  type: "error";
  message: string;
  errorCode: number;
  supportUrl?: string;
};

type Notification = InfoNotification | WarningNotification | ErrorNotification;

このコードでは、3種類の通知(info・warning・error)を扱うために、それぞれ異なる構造の型を定義しています。

  • InfoNotification:情報表示用の通知
  • WarningNotification:注意喚起用で、再試行までの時間を含む
  • ErrorNotification:エラー用で、エラーコードやサポートURLを持つ

それぞれに共通しているのは type プロパティです。この type が「判別子(discriminator)」となり、どの型なのかを判別するために使われます。


Notification 型の意味

type Notification = InfoNotification | WarningNotification | ErrorNotification;

これは ユニオン型です。つまり、Notification 型の変数には 3つのうちどれか1つの型の値を代入できます。

const info: Notification = {
  type: "info",
  message: "システムは正常に動作しています。"
};

const warning: Notification = {
  type: "warning",
  message: "サーバーが一時的に混雑しています。",
  retryAfterSeconds: 30,
};

const error: Notification = {
  type: "error",
  message: "データの取得に失敗しました。",
  errorCode: 500,
  supportUrl: "https://support.example.com",
};

なぜ type を使うのか?

もし type がなければ、どの構造に該当するのかを判別するのが困難になります。
例えばこんな関数を考えてみてください。

function handle(notification: Notification) {
  if ("retryAfterSeconds" in notification) {
    // warning型かも?
  } else if ("errorCode" in notification) {
    // error型?
  }
}

↑こうすると、プロパティの存在を使って判別しなければならず、分かりにくくて安全性も低いです。

一方で type を判別子として使うと、次のように明示的かつ安全に型を判別できます:

function handle(notification: Notification) {
  if (notification.type === "info") {
    // notification は InfoNotification 型として扱われる
    console.log(notification.message);
  } else if (notification.type === "warning") {
    // WarningNotification 型にナローイングされる
    console.log(`⚠️ ${notification.message}(再試行まで ${notification.retryAfterSeconds} 秒)`);
  } else if (notification.type === "error") {
    // ErrorNotification 型にナローイングされる
    console.log(`❌ エラー: ${notification.message}(コード: ${notification.errorCode})`);
  }
}

TypeScript は type の値を見て、自動的にその型に「絞り込み(ナローイング)」してくれるのです。

2. switch文による網羅性チェック

ユニオン型に対して switch 文で条件分岐を行う際に、「すべての可能な型(ケース)を漏れなく処理しているかどうか」をコンパイル時にチェックする仕組みのことです。

これにより、将来ユニオン型に新しい型が追加されたときに、分岐漏れを防げます。

例:判別可能ユニオンを switch で分岐

type Notification =
  | { type: "info"; message: string }
  | { type: "warning"; message: string; retryAfterSeconds: number }
  | { type: "error"; message: string; errorCode: number };

function handleNotification(notification: Notification) {
  switch (notification.type) {
    case "info":
      console.log(notification.message);
      break;
    case "warning":
      console.log(`${notification.message}(再試行まで ${notification.retryAfterSeconds} 秒)`);
      break;
    case "error":
      console.log(`${notification.message}(エラーコード: ${notification.errorCode})`);
      break;
  }
}

このコードは今の時点では問題ありません。ただし、もし後から type: "success" のような通知タイプが追加された場合、switch 文はそれに対応しておらず、バグの温床になります。

解決策:never 型を使った網羅性チェック

function handleNotification(notification: Notification) {
  switch (notification.type) {
    case "info":
      console.log(notification.message);
      break;
    case "warning":
      console.log(`${notification.message}(再試行まで ${notification.retryAfterSeconds} 秒)`);
      break;
    case "error":
      console.log(`${notification.message}(エラーコード: ${notification.errorCode})`);
      break;
    default:
      // このコードが保証するのは「すべてのcaseが処理されているか」
      const _: never = notification;
      throw new Error("未処理の通知タイプです");
  }
}

ここでの const _: never = notification; の意図は次の通りです:

  • notification"info" | "warning" | "error" のいずれかであれば、すでに case で処理済みなので never 型になっているはず
  • もしユニオン型に新しい "success" などの値が追加されて case が足りていなければ、notification の型は never ではなくなる
  • その結果、never 型への代入に失敗し、TypeScriptがコンパイルエラーを出してくれる

この仕組みにより、以下のようなコードの安全性が高まります:

  • 型の追加漏れを事前に防止できる
  • レビューやテストでの人的ミスを軽減できる
  • 将来の仕様変更にも強い構造になる

補足:if/else では網羅性チェックできない理由

switch 文では default + never を使うことで網羅性チェックが可能ですが、if/else if の分岐構造では、TypeScript はすべての可能性を静的にチェックすることができません。

if (notification.type === "info") {
  // ...
} else if (notification.type === "warning") {
  // ...
} // "error" を忘れてもコンパイルエラーにならない

そのため、ユニオン型の全パターンを確実に処理する必要がある場面では、switch 文を使うのが望ましいです。

3. 判別可能ユニオンが活きる現実的なユースケース

APIレスポンスの表現

Webアプリケーションでは、バックエンドAPIから返されるレスポンスが成功時と失敗時で異なる構造を持つことがよくあります。

例:ログインAPIのレスポンス

type LoginSuccess = {
  type: "success";
  token: string;
  userId: string;
};

type LoginFailure = {
  type: "error";
  message: string;
};

type LoginResponse = LoginSuccess | LoginFailure;

このようにすることで、フロントエンドでは以下のような安全な処理が可能になります。

function handleLogin(response: LoginResponse) {
  if (response.type === "success") {
    // 成功時の型に絞られているため、補完が効き安全に扱える
    localStorage.setItem("token", response.token);
  } else if (response.type === "error") {
    alert(`ログイン失敗: ${response.message}`);
  }
}
  • 型の絞り込みが可能 → エラー発生時の処理漏れを防げる
  • switch と組み合わせて網羅性チェックも可能
  • フロントエンドとバックエンドの契約(schema)として明確になる

フォーム入力の型定義

1つのフォームで複数の入力モード(たとえば「住所入力」「連絡先入力」など)を切り替える場合、それぞれに異なる項目があります。

例:ユーザープロフィール編集フォーム

type AddressForm = {
  type: "address";
  postalCode: string;
  prefecture: string;
  city: string;
};

type ContactForm = {
  type: "contact";
  email: string;
  phone: string;
};

type ProfileForm = AddressForm | ContactForm;

type を基準に表示・検証する項目を切り替えることで、UIの構築とバリデーションが明確に分岐可能になります。

function renderForm(form: ProfileForm) {
  if (form.type === "address") {
    // 住所入力フィールドを表示
  } else if (form.type === "contact") {
    // メール・電話番号入力フィールドを表示
  }
}
  • フォームの入力モードごとのUIやバリデーションの定義を型で制御できる
  • type に応じた明示的な切り替えが可能になり、バグを防げる
  • 実際のユーザー操作と密接に結びつく処理で、保守性が高くなる

状態管理

アプリの状態(たとえば通信の状態や表示内容)を1つのユニオン型で管理することで、状態ごとに異なる描画や処理を明確に分岐できるようになります。

例:データのロード状態

type LoadingState = {
  type: "loading";
};

type SuccessState<T> = {
  type: "success";
  data: T;
};

type ErrorState = {
  type: "error";
  message: string;
};

type FetchState<T> = LoadingState | SuccessState<T> | ErrorState;

これにより、データフェッチの各状態に応じた表示が明確になります。

function render(state: FetchState<User[]>) {
  switch (state.type) {
    case "loading":
      return <p>読み込み中です...</p>;
    case "success":
      return <UserList data={state.data} />;
    case "error":
      return <p>エラー: {state.message}</p>;
  }
}
  • 表示・非表示・エラーなどの状態を型で完全に管理できる
  • 状態の追加(例:"empty"など)にも型で対応可能
  • switch + never によって分岐漏れを検知できる

まとめ

1. 判別子(type など)で明示的に型を分ける

各型に共通する「判別子」プロパティを持たせることで、どの型であるかを一意に識別できます。これにより、TypeScriptの型推論によって自然にナローイングが行われ、安全にプロパティへアクセスできます。

2. switch 文と never を組み合わせることで網羅性を確保できる

ユニオン型に新しいケースが追加されたときに、既存のコードに分岐漏れがないかをコンパイル時にチェックできます。これにより、将来的な拡張にも強いコードが書けます。

3. 実用的なユースケースが多い

判別可能ユニオンは、以下のような実務でも頻繁に利用されます。

  • APIレスポンスの型定義(成功・エラーなどのパターン分岐)
  • フォーム入力の種類による構造の違い
  • 通信状態(読み込み中・成功・失敗など)を状態管理で表現する

4. 型安全で意図の明確なコードを実現できる

プロパティの存在チェックや型アサーション(as)に頼らず、明示的に処理を分岐できるため、安全で保守しやすく、読みやすいコードになります。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?