TypeScriptのUnion Type活用術:APIレスポンスで学ぶ実践的な型設計

に公開

はじめに

フロントエンド開発において、TypeScriptのUnion Typeは強力な型安全性を提供する重要な機能です。特にAPIレスポンスの型定義では、Union Typeを使うかOptional Propertiesを使うかで、実装が変わります。

具体例:ユーザー検索APIのレスポンス

以下のようなユーザー検索APIがあるとします:

// 成功時のレスポンス
{
  "status": "success",
  "data": {
    "users": [
      {
        "id": "123",
        "name": "田中太郎",
        "email": "tanaka@example.com"
      }
    ],
    "totalCount": 10,
    "hasMore": true
  }
}

// エラー時のレスポンス
{
  "status": "error",
  "error": {
    "code": "INVALID_QUERY",
    "message": "検索クエリが無効です",
    "details": {
      "field": "name",
      "reason": "文字数が不足しています"
    }
  }
}

// ロード中のレスポンス(フロントエンドの状態管理用)
{
  "status": "loading"
}

このAPIレスポンスに対して、Union TypeOptional Propertiesの2つの異なるアプローチで型を定義してみましょう。

アプローチ1:Union Type(厳格な型定義)

// 厳格な型定義
type ApiResponse = {
  status: 'success';
  data: {
    users: User[];
    totalCount: number;
    hasMore: boolean;
  };
} | {
  status: 'error';
  error: {
    code: string;
    message: string;
    details?: {
      field: string;
      reason: string;
    };
  };
} | {
  status: 'loading';
};

type User = {
  id: string;
  name: string;
  email: string;
};

// 実装例
function handleResponse(response: ApiResponse) {
  switch (response.status) {
    case 'success':
      // TypeScriptがresponse.dataの存在を保証
      console.log(`${response.data.users.length}件のユーザーが見つかりました`);
      response.data.users.forEach(user => {
        console.log(`${user.name} (${user.email})`);
      });
      break;
      
    case 'error':
      // TypeScriptがresponse.errorの存在を保証
      console.error(`エラー: ${response.error.message}`);
      if (response.error.details) {
        console.error(`詳細: ${response.error.details.reason}`);
      }
      break;
      
    case 'loading':
      // loadingの場合は追加のプロパティなし
      console.log('読み込み中...');
      break;
  }
}

アプローチ2:Optional Properties(緩やかな型定義)

// 緩やかな型定義
interface ApiResponse {
  status: 'success' | 'error' | 'loading';
  data?: {
    users: User[];
    totalCount: number;
    hasMore: boolean;
  };
  error?: {
    code: string;
    message: string;
    details?: {
      field: string;
      reason: string;
    };
  };
}

type User = {
  id: string;
  name: string;
  email: string;
};

// 実装例
function handleResponse(response: ApiResponse) {
  switch (response.status) {
    case 'success':
      // 実行時チェックが必要
      if (response.data) {
        console.log(`${response.data.users.length}件のユーザーが見つかりました`);
        response.data.users.forEach(user => {
          console.log(`${user.name} (${user.email})`);
        });
      }
      break;
      
    case 'error':
      // 実行時チェックが必要
      if (response.error) {
        console.error(`エラー: ${response.error.message}`);
        if (response.error.details) {
          console.error(`詳細: ${response.error.details.reason}`);
        }
      }
      break;
      
    case 'loading':
      console.log('読み込み中...');
      break;
  }
}

Union Type vs Optional Properties:メリット・デメリットの比較

Union Typeのメリット

1. 型安全性の向上

// ✅ コンパイル時にエラーを検出
function processSuccess(response: ApiResponse) {
  if (response.status === 'success') {
    // response.dataが確実に存在することをTypeScriptが保証
    return response.data.users.map(user => user.name);
  }
}

2. 不正なデータ構造の防止

// ❌ このような不正な組み合わせはコンパイルエラーになる
const invalidResponse: ApiResponse = {
  status: 'success',
  error: { code: 'ERR', message: 'error' } // エラー: successにerrorは存在しない
};

3. IDEのサポートが充実

  • 自動補完が正確
  • リファクタリング時の変更箇所を正確に検出
  • 未使用プロパティの警告

Union Typeのデメリット

1. 型定義が複雑になる

// 新しいステータスを追加する場合、Union Typeを拡張する必要がある
type ApiResponse = {
  status: 'success';
  data: SuccessData;
} | {
  status: 'error';
  error: ErrorData;
} | {
  status: 'loading';
} | {
  status: 'partial'; // 新しいステータス
  data: PartialData;
  warnings: Warning[];
};

2. 共通プロパティの重複

// statusやtimestampなどの共通プロパティが重複する
type ApiResponse = {
  status: 'success';
  timestamp: string; // 重複
  requestId: string; // 重複
  data: SuccessData;
} | {
  status: 'error';
  timestamp: string; // 重複
  requestId: string; // 重複
  error: ErrorData;
};

Optional Propertiesのメリット

1. シンプルで理解しやすい

// 一目で全体の構造が把握できる
interface ApiResponse {
  status: 'success' | 'error' | 'loading';
  data?: SuccessData;
  error?: ErrorData;
  timestamp?: string;
  requestId?: string;
}

2. 拡張が容易

// 新しいプロパティの追加が簡単
interface ApiResponse {
  status: 'success' | 'error' | 'loading' | 'partial';
  data?: SuccessData;
  error?: ErrorData;
  warnings?: Warning[]; // 簡単に追加できる
  metadata?: Metadata; // 簡単に追加できる
}

3. 外部APIとの統合が楽

// 外部APIの仕様変更に柔軟に対応
interface ThirdPartyApiResponse {
  status: string; // 予期しない値も受け入れ可能
  data?: any;
  error?: any;
  [key: string]: any; // 追加のプロパティも許容
}

Optional Propertiesのデメリット

1. Optional Chainingが常に必要

// ❌ 毎回 ?. を付けなければならない
function processResponse(response: ApiResponse) {
  console.log(response.data?.users?.length); // ?. が必要
  console.log(response.data?.totalCount); // ?. が必要
  console.log(response.error?.message); // ?. が必要
  
  // 配列の操作でも常に存在チェックが必要
  response.data?.users?.forEach(user => {
    console.log(user.name);
  });
}

2. ランタイムエラーのリスク

// ❌ Optional Chainingを忘れると実行時にクラッシュ
function processResponse(response: ApiResponse) {
  if (response.status === 'success') {
    // response.dataがundefinedの場合、実行時エラー
    return response.data.users.length; // TypeError: Cannot read property 'users' of undefined
  }
}

2. 不正なデータの組み合わせを許容

// ❌ 論理的におかしい組み合わせもコンパイルが通ってしまう
const invalidResponse: ApiResponse = {
  status: 'success',
  error: { code: 'ERR', message: 'error' } // 論理的には矛盾
};

3. 防御的プログラミングが必要

// 常に存在チェックが必要
function handleResponse(response: ApiResponse) {
  if (response.status === 'success' && response.data) {
    // 二重チェックが必要
    if (response.data.users && Array.isArray(response.data.users)) {
      // さらに配列の型チェックも必要
      response.data.users.forEach(user => {
        if (user && user.name && user.email) {
          console.log(`${user.name} (${user.email})`);
        }
      });
    }
  }
}

コードの書きやすさの比較

Union Typeでの実装

// ✅ シンプルで直感的
function displayUsers(response: ApiResponse) {
  switch (response.status) {
    case 'success':
      // ?. 不要!TypeScriptがdataの存在を保証
      console.log(`総件数: ${response.data.totalCount}`);
      response.data.users.forEach(user => {
        console.log(`${user.name} (${user.email})`);
      });
      break;
    case 'error':
      // ?. 不要!TypeScriptがerrorの存在を保証
      console.error(response.error.message);
      break;
  }
}

Optional Propertiesでの実装

// ❌ 毎回 Optional Chaining や 存在チェックが必要
function displayUsers(response: ApiResponse) {
  switch (response.status) {
    case 'success':
      // 毎回 ?. を付ける必要がある
      console.log(`総件数: ${response.data?.totalCount ?? 0}`);
      response.data?.users?.forEach(user => {
        console.log(`${user.name} (${user.email})`);
      });
      break;
    case 'error':
      // エラーメッセージも ?. が必要
      console.error(response.error?.message ?? 'Unknown error');
      break;
  }
}

// または、より安全な書き方
function displayUsersWithGuard(response: ApiResponse) {
  if (response.status === 'success' && response.data) {
    console.log(`総件数: ${response.data.totalCount}`);
    response.data.users.forEach(user => {
      console.log(`${user.name} (${user.email})`);
    });
  } else if (response.status === 'error' && response.error) {
    console.error(response.error.message);
  }
}

Reactでの使用例

// Union Type
function UserList({ response }: { response: ApiResponse }) {
  switch (response.status) {
    case 'success':
      return (
        <div>
          <p>総件数: {response.data.totalCount}</p>
          {response.data.users.map(user => (
            <div key={user.id}>{user.name}</div>
          ))}
        </div>
      );
    case 'error':
      return <div>エラー: {response.error.message}</div>;
    case 'loading':
      return <div>読み込み中...</div>;
  }
}

// 緩やかな型定義
function UserList({ response }: { response: ApiResponse }) {
  if (response.status === 'success' && response.data) {
    return (
      <div>
        <p>総件数: {response.data.totalCount}</p>
        {response.data.users?.map(user => (
          <div key={user.id}>{user.name}</div>
        ))}
      </div>
    );
  }
  
  if (response.status === 'error') {
    return <div>エラー: {response.error?.message ?? 'Unknown error'}</div>;
  }
  
  return <div>読み込み中...</div>;
}

実践的な選択指針

Union Typeを選ぶべき場合

1. 内部API(自社開発)

  • 仕様が明確に定まっている
  • レスポンス構造の変更をコントロールできる
  • 長期的な保守性を重視する

2. 重要なビジネスロジック

  • データの整合性が重要
  • バグによる影響が大きい

3. 大規模なチーム開発

  • 複数の開発者が同じコードを触る
  • コードレビューでのミス発見を重視
  • 型による自動テストを活用したい

Optional Propertiesを選ぶべき場合

1. 外部API(サードパーティ)

  • 仕様変更をコントロールできない
  • ドキュメントが不完全な場合がある
  • レスポンス構造が頻繁に変わる可能性

2. プロトタイプ開発

  • 要件が流動的
  • 開発速度を重視
  • 後でリファクタリング予定

3. レガシーシステムとの連携

  • データ構造が複雑で予測困難
  • 段階的な移行が必要
  • 既存システムの制約が多い

実際のプロジェクトでの使い分け例

// 内部API: Union Type
type InternalApiResponse = {
  status: 'success';
  data: UserData;
} | {
  status: 'error';
  error: ApiError;
};

// 外部API: Optional Properties
interface ExternalApiResponse {
  status?: string;
  data?: any;
  error?: any;
  [key: string]: any;
}

// ハイブリッド: 基本構造はUnion Type、詳細はOptional
type HybridApiResponse = {
  status: 'success';
  data: {
    users: User[];
    metadata?: Record<string, any>; // 将来の拡張に備える
  };
} | {
  status: 'error';
  error: {
    code: string;
    message: string;
    [key: string]: any; // 詳細なエラー情報は柔軟に
  };
};

まとめ

APIレスポンスの型定義において、厳格さと緩やかさの選択は、プロジェクトの性質と開発フェーズに大きく依存します。

重要なポイント:

  • 内部APIは厳格に、外部APIは緩やかに
  • プロダクション環境では厳格に、プロトタイプでは緩やかに
  • チーム開発では厳格に、個人開発では状況に応じて

適切な型設計により、開発効率と品質のバランスを取りながら、保守しやすいコードベースを構築できます。

Discussion