Chapter 04

ユニオン型とリテラル型

複数の型を組み合わせるユニオン型と型の絞り込みを学びます

30 min

このチャプターで学ぶこと

  • ユニオン型で複数の型を表現できる
  • リテラル型で特定の値に制限できる
  • 型の絞り込み(ナローイング)を使える
  • 判別付きユニオンパターンを理解できる

ユニオン型とリテラル型

前のチャプターでは interfacetype でオブジェクトの形を定義しました。しかし現実のアプリケーションでは「文字列または数値」「成功またはエラー」のように、複数の可能性を持つ値を扱うことが頻繁にあります。

このチャプターでは、TypeScript の最も強力な機能のひとつであるユニオン型と、それと密接に関わるリテラル型、**型の絞り込み(ナローイング)**を学びます。

ユニオン型とは

ユニオン型は |(パイプ)で区切って「AまたはB」という型を表現します。

// string または number を受け取る
let id: string | number;

id = "abc-123";  // OK:string
id = 42;         // OK:number
id = true;       // エラー:boolean は割り当てられない

関数の引数にも使えます。

// ID が文字列でも数値でも受け取れる関数
function findUser(id: string | number): void {
  console.log(`ユーザーを検索: ${id}`);
}

findUser("abc-123"); // OK
findUser(42);        // OK
findUser(true);      // エラー

LaravelやRailsのAPIでは、IDが数値のこともあればUUIDの文字列のこともありますよね。ユニオン型はまさにそういった「複数の型を受け取る」場面に対応します。

ユニオン型を使うときの注意点

ユニオン型の値に対しては、すべての型に共通する操作しかできません

function printId(id: string | number): void {
  // エラー:number に toUpperCase メソッドはない
  console.log(id.toUpperCase());

  // OK:toString() は string にも number にもある
  console.log(id.toString());
}

string | number 型の値に対して toUpperCase() を呼べないのは、idnumber かもしれないからです。この制限を解決するのが**型の絞り込み(ナローイング)**です。

リテラル型

TypeScript では特定の値そのものを型として使えます。これをリテラル型と呼びます。

// "admin" という文字列リテラル型
let role: "admin";
role = "admin"; // OK
role = "user";  // エラー:"user" は "admin" に割り当てられない

// 数値のリテラル型
let statusCode: 200 | 404 | 500;
statusCode = 200; // OK
statusCode = 201; // エラー

単体ではあまり使いませんが、ユニオン型と組み合わせると許可する値を限定できます。

// HTTPメソッドを特定の文字列に限定
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";

function sendRequest(method: HttpMethod, url: string): void {
  console.log(`${method} ${url}`);
}

sendRequest("GET", "/api/users");    // OK
sendRequest("POST", "/api/users");   // OK
sendRequest("FETCH", "/api/users");  // エラー:"FETCH" は HttpMethod に割り当てられない
💡 ヒント

Laravelのバリデーションルールでいえば 'status' => 'in:active,inactive,pending' に似ています。TypeScriptでは type Status = "active" | "inactive" | "pending" と書くことで、コンパイル時に不正な値を弾けます。

リテラル型の実用例

React アプリケーションでよく使われるパターンを見てみましょう。

// ボタンのバリエーション
type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";

// サイズの指定
type Size = "sm" | "md" | "lg";

interface ButtonProps {
  label: string;
  variant?: ButtonVariant;  // 省略時はデフォルト値を使う
  size?: Size;
  disabled?: boolean;
}
function Button({ label, variant = "primary", size = "md", disabled = false }: ButtonProps) {
  return (
    <button
      className={`btn btn-${variant} btn-${size}`}
      disabled={disabled}
    >
      {label}
    </button>
  );
}

// 使う側:エディタが variant の候補を自動補完してくれる
<Button label="保存" variant="primary" />
<Button label="削除" variant="danger" />
<Button label="やめる" variant="ghost" />
<Button label="送信" variant="success" /> // エラー:"success" は ButtonVariant にない

エディタが variant に入力できる値を補完してくれるので、タイポやスペルミスを完全に防げます。

型の絞り込み(ナローイング)

ユニオン型の値を安全に使うためには、「今この値はどの型なのか」を TypeScript に教える必要があります。これを**型の絞り込み(ナローイング)**と呼びます。

typeof による絞り込み

最も基本的なナローイングは typeof 演算子です。

function formatId(id: string | number): string {
  if (typeof id === "string") {
    // このブロック内では id は string として扱われる
    return id.toUpperCase();
  } else {
    // このブロック内では id は number として扱われる
    return id.toFixed(0);
  }
}

formatId("abc-123"); // "ABC-123"
formatId(42);        // "42"

typeof チェックの後、TypeScript は自動的に型を絞り込みます。if ブロック内では string として、else ブロック内では number としてオートコンプリートが効きます。

truthiness(真偽値)による絞り込み

nullundefined を含むユニオン型では、真偽値チェックが有効です。

function greetUser(name: string | null): string {
  if (name) {
    // name は string(null は falsy なのでここに来ない)
    return `こんにちは、${name}さん`;
  }
  return "こんにちは、ゲストさん";
}

// optional プロパティのチェック
interface User {
  name: string;
  nickname?: string;
}

function getDisplayName(user: User): string {
  if (user.nickname) {
    // user.nickname は string
    return user.nickname;
  }
  return user.name;
}
⚠️ 注意

真偽値チェックには落とし穴があります。0"" (空文字列)も falsy な値です。

function formatCount(count: number | null): string {
  if (count) {
    // 注意:count が 0 のときもここに来ない!
    return `${count}`;
  }
  return "データなし";
}

formatCount(0);    // "データなし" ← 0件のはずが「データなし」に!
formatCount(null); // "データなし"

// 正しい書き方
function formatCountCorrect(count: number | null): string {
  if (count !== null) {
    return `${count}`;
  }
  return "データなし";
}

数値の 0 や空文字列 "" が有効な値である場合は、!== null!== undefined で明示的にチェックしましょう。

in 演算子による絞り込み

オブジェクトに特定のプロパティがあるかどうかで型を絞り込めます。

interface Dog {
  name: string;
  bark: () => void;
}

interface Cat {
  name: string;
  meow: () => void;
}

function makeSound(animal: Dog | Cat): void {
  if ("bark" in animal) {
    // animal は Dog
    animal.bark();
  } else {
    // animal は Cat
    animal.meow();
  }
}

判別付きユニオン(Discriminated Unions)

TypeScript で最も強力なパターンのひとつが判別付きユニオンです。共通のプロパティ(判別子)を持つ複数の型をユニオンで組み合わせ、その判別子の値で型を絞り込みます。

API レスポンスの型定義

バックエンド開発者にとって馴染みのあるケース──APIレスポンスの成功と失敗を型で表現してみましょう。

// 成功レスポンス
interface SuccessResponse {
  status: "success";        // 判別子(リテラル型)
  data: {
    users: User[];
    totalCount: number;
  };
}

// エラーレスポンス
interface ErrorResponse {
  status: "error";          // 判別子(リテラル型)
  error: {
    code: number;
    message: string;
  };
}

// ローディング状態
interface LoadingResponse {
  status: "loading";        // 判別子(リテラル型)
}

// 3つの状態をユニオンで表現
type ApiResponse = SuccessResponse | ErrorResponse | LoadingResponse;

status プロパティが判別子です。status の値をチェックすることで、TypeScript はどの型なのかを自動的に判別します。

function handleResponse(response: ApiResponse): void {
  switch (response.status) {
    case "loading":
      // response は LoadingResponse
      console.log("読み込み中...");
      break;

    case "success":
      // response は SuccessResponse
      // data プロパティにアクセスできる
      console.log(`${response.data.totalCount}件のユーザーを取得`);
      response.data.users.forEach((user) => {
        console.log(user.name);
      });
      break;

    case "error":
      // response は ErrorResponse
      // error プロパティにアクセスできる
      console.log(`エラー ${response.error.code}: ${response.error.message}`);
      break;
  }
}
📝 メモ

switch 文で status をチェックすると、各 case ブロック内で TypeScript が自動的に正しい型に絞り込みます。"success"case 内では response.data にアクセスでき、"error"case 内では response.error にアクセスできます。

React での状態管理に活用

このパターンは React コンポーネントの状態管理で非常によく使われます。

// APIの取得状態を判別付きユニオンで定義
type FetchState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: string };

// コンポーネントで使用
function UserList() {
  const [state, setState] = useState<FetchState<User[]>>({ status: "idle" });

  // 状態に応じた表示の切り替え
  switch (state.status) {
    case "idle":
      return <p>検索してください</p>;
    case "loading":
      return <p>読み込み中...</p>;
    case "success":
      return (
        <ul>
          {state.data.map((user) => (
            <li key={user.id}>{user.name}</li>
          ))}
        </ul>
      );
    case "error":
      return <p className="error">{state.error}</p>;
  }
}

status の値によって、dataerror にアクセスできるかどうかが自動的に決まります。「ローディング中なのにデータにアクセスする」といった論理的なバグをコンパイル時に防げるのです。

フォームアクションの型定義

もう一つの実践例として、フォームの送信結果を判別付きユニオンで定義してみましょう。

// フォーム送信の結果
type FormResult =
  | { type: "idle" }
  | { type: "submitting" }
  | { type: "success"; message: string }
  | { type: "validationError"; errors: Record<string, string[]> }
  | { type: "serverError"; message: string };

function handleFormResult(result: FormResult): void {
  switch (result.type) {
    case "idle":
      // 何もしない
      break;

    case "submitting":
      // ボタンを無効化
      console.log("送信中...");
      break;

    case "success":
      console.log(result.message);
      break;

    case "validationError":
      // バリデーションエラーの表示
      Object.entries(result.errors).forEach(([field, messages]) => {
        messages.forEach((msg) => {
          console.log(`${field}: ${msg}`);
        });
      });
      break;

    case "serverError":
      console.log(`サーバーエラー: ${result.message}`);
      break;
  }
}
💡 ヒント

Laravelのバリデーションエラーレスポンスを思い出してください。422 のとき errors オブジェクトが返り、500 のとき message が返る──この違いを TypeScript の判別付きユニオンで型安全に表現できます。

網羅性チェック(Exhaustiveness Check)

判別付きユニオンのもう一つの強力な機能が網羅性チェックです。すべてのケースを処理しているかどうかをコンパイラがチェックしてくれます。

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rectangle"; width: number; height: number }
  | { kind: "triangle"; base: number; height: number };

function getArea(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    case "triangle":
      return (shape.base * shape.height) / 2;
  }
}

もし将来 Shape に新しいバリアント(例:"pentagon")を追加したとき、switch 文で処理し忘れるとコンパイルエラーになります。

// never 型を使った明示的な網羅性チェック
function getAreaStrict(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    case "triangle":
      return (shape.base * shape.height) / 2;
    default:
      // すべてのケースを処理していれば、ここは never 型
      const _exhaustiveCheck: never = shape;
      return _exhaustiveCheck;
  }
}

never 型はどんな値も割り当てられない型です。すべてのケースが処理されていれば default に到達することはなく、shapenever 型になります。処理漏れがあるとコンパイルエラーになるため、網羅性が保証されます。

よくあるナローイングの落とし穴

コールバック内でのナローイング

function processValue(value: string | number): void {
  if (typeof value === "string") {
    // ここでは value は string
    console.log(value.toUpperCase());

    // コールバック内ではナローイングが解除される場合がある
    setTimeout(() => {
      // value は再び string | number として扱われることがある
      // TypeScript のバージョンによって挙動が異なる
      console.log(value.toUpperCase()); // 現在のバージョンでは多くの場合OK
    }, 1000);
  }
}

配列のフィルタリング

const items: (string | null)[] = ["a", null, "b", null, "c"];

// filter だけでは型が絞り込まれない
const filtered = items.filter((item) => item !== null);
// filtered の型は (string | null)[] のまま...

// 型述語を使って絞り込む
const filtered2 = items.filter((item): item is string => item !== null);
// filtered2 の型は string[]
📝 メモ

item is string は**型述語(Type Predicate)**と呼ばれる構文です。filter のコールバックが true を返した要素は string 型であるとTypeScriptに教えています。これはやや上級のテクニックですが、実務ではよく使います。

✍ やってみよう:判別付きユニオンを作る

以下の要件を満たす型と関数を作成してください。

1. 通知の型を定義する

アプリケーションの通知を判別付きユニオンで表現します。

// 以下の3種類の通知がある
// - info: メッセージのみ
// - warning: メッセージと対処方法
// - error: メッセージ、エラーコード、リトライ可能かどうか

type Notification = // ここを完成させる

2. 通知を処理する関数を作る

function formatNotification(notification: Notification): string {
  // switch 文で各種類の通知をフォーマットする
  // info: "[INFO] メッセージ"
  // warning: "[WARNING] メッセージ - 対処法: ..."
  // error: "[ERROR-コード] メッセージ" + リトライ可能なら " (リトライ可能)" を追加
}

3. 通知の配列を処理する

const notifications: Notification[] = [
  { type: "info", message: "システムメンテナンスは完了しました" },
  { type: "warning", message: "ディスク容量が80%です", action: "不要なファイルを削除してください" },
  { type: "error", message: "データベース接続に失敗", code: 503, retryable: true },
];

// すべての通知をフォーマットして表示
notifications.forEach((n) => {
  console.log(formatNotification(n));
});

ヒント:判別子(type プロパティ)にリテラル型を使い、各バリアントに固有のプロパティを定義しましょう。

まとめ

このチャプターで学んだことをまとめます:

判別付きユニオンは React の状態管理(ローディング・成功・エラー)やフォーム処理で頻繁に使うパターンです。ぜひ手を動かして身に付けてください。

次のチャプターでは、関数の型を学びます。パラメータ、戻り値、コールバック関数の型定義を通じて、React のイベントハンドラを型安全に書く準備をします。