TypeScript Union Type の実務活用ガイド|型安全性を高める実装パターン

TypeScript

TypeScript Union Type の実務活用ガイド|型安全性を高める実装パターン

はじめに

TypeScript を日々の開発で使う中で、Union Type(ユニオン型)は避けて通れない機能です。しかし教科書的な説明だけでは、実際の業務でどう活用すればいいのか判断に迷うことも多いでしょう。本記事では、実務で頻繁に出現するユースケースに焦点を当て、具体的な実装パターンを紹介します。

Union Type の基本理解

Union Type は、複数の型を組み合わせて「この値は複数の型のいずれかを持つ」と宣言する機能です。基本的な書き方は次のとおりです。

type Status = 'pending' | 'success' | 'error';
type Result = string | number | boolean;

単純な例では型の列挙ですが、実務ではより複雑な構造を扱うことがほとんどです。複数のオブジェクト型を組み合わせるケースが典型的です。

type ApiResponse = 
  | { status: 'success'; data: User[] }
  | { status: 'error'; errorCode: number; message: string }
  | { status: 'loading' };

この構造により、API レスポンスの複数のパターンを型レベルで管理できます。

実務での主なユースケース

1. API レスポンスの型定義

最も頻繁に出現するパターンです。Web API のレスポンスは成功時と失敗時で全く異なる構造を持つため、Union Type の出番です。

// API レスポンスの型定義
type FetchUserResponse = 
  | { ok: true; data: { id: number; name: string; email: string } }
  | { ok: false; error: { code: string; message: string } };

// 実装例
async function fetchUser(userId: number): Promise<FetchUserResponse> {
  try {
    const response = await fetch(`/api/users/${userId}`);
    
    if (!response.ok) {
      return {
        ok: false,
        error: {
          code: 'FETCH_ERROR',
          message: `HTTP ${response.status}: ${response.statusText}`
        }
      };
    }
    
    const data = await response.json();
    return { ok: true, data };
  } catch (err) {
    return {
      ok: false,
      error: {
        code: 'NETWORK_ERROR',
        message: err instanceof Error ? err.message : 'Unknown error'
      }
    };
  }
}

// 呼び出し側
const result = await fetchUser(123);
if (result.ok) {
  console.log(result.data.name); // ok が true なら data にアクセス可能
} else {
  console.error(result.error.message);
}

2. イベントハンドリングと状態管理

React や Vue などのフレームワークでの状態管理では、複数の状態パターンを Union Type で表現するのが効果的です。

// 非同期処理の状態パターン
type AsyncState<T> = 
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error };

// 使用例
type UserState = AsyncState<{ id: number; name: string; email: string }>;

let userState: UserState = { status: 'idle' };

// 状態遷移
userState = { status: 'loading' };

userState = {
  status: 'success',
  data: { id: 1, name: 'Taro Yamada', email: 'taro@example.com' }
};

// 状態に応じた処理
function renderUserState(state: UserState): string {
  switch (state.status) {
    case 'idle':
      return 'Ready to load';
    case 'loading':
      return 'Loading...';
    case 'success':
      return `Welcome, ${state.data.name}`;
    case 'error':
      return `Error: ${state.error.message}`;
  }
}

3. ペイロード型の統一化

イベントやメッセージの種類が複数ある場合、Union Type で統一的に管理できます。これは Redux や Zustand などの状態管理ライブラリでも採用されているパターンです。

// イベントペイロードの定義
type Event = 
  | { type: 'USER_LOGIN'; payload: { userId: number; timestamp: Date } }
  | { type: 'USER_LOGOUT'; payload: { userId: number } }
  | { type: 'PRODUCT_ADDED_TO_CART'; payload: { productId: number; quantity: number } }
  | { type: 'ORDER_COMPLETED'; payload: { orderId: string; total: number } };

// イベントハンドラー
function handleEvent(event: Event): void {
  switch (event.type) {
    case 'USER_LOGIN':
      console.log(`User ${event.payload.userId} logged in at ${event.payload.timestamp}`);
      break;
    case 'USER_LOGOUT':
      console.log(`User ${event.payload.userId} logged out`);
      break;
    case 'PRODUCT_ADDED_TO_CART':
      console.log(`Added ${event.payload.quantity} of product ${event.payload.productId}`);
      break;
    case 'ORDER_COMPLETED':
      console.log(`Order ${event.payload.orderId} completed: ¥${event.payload.total}`);
      break;
  }
}

// ロギング関数の例
function logEvent(event: Event): void {
  const timestamp = new Date().toISOString();
  console.log(JSON.stringify({ timestamp, event }, null, 2));
}

実装コード|実務で使える完全な例

ここでは、実務で実際に機能する、より現実的な例を示します。ユーザー認証とそれに伴う複数の状態を扱うシナリオです。

// ドメインモデルの定義
interface User {
  id: number;
  email: string;
  name: string;
  role: 'admin' | 'user';
}

interface AuthError {
  code: string;
  message: string;
  retryable: boolean;
}

// 認証状態の Union Type
type AuthState = 
  | { status: 'unauthenticated' }
  | { status: 'authenticating' }
  | { status: 'authenticated'; user: User; token: string }
  | { status: 'error'; error: AuthError };

// 認証サービス
class AuthService {
  private state: AuthState = { status: 'unauthenticated' };

  getState(): AuthState {
    return this.state;
  }

  async login(email: string, password: string): Promise<void> {
    this.state = { status: 'authenticating' };

    try {
      const response = await fetch('/api/auth/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email, password })
      });

      if (!response.ok) {
        const errorData = await response.json();
        this.state = {
          status: 'error',
          error: {
            code: errorData.code || 'LOGIN_FAILED',
            message: errorData.message || 'Login failed',
            retryable: response.status !== 401
          }
        };
        return;
      }

      const { user, token } = await response.json();
      this.state = { status: 'authenticated', user, token };
    } catch (err) {
      this.state = {
        status: 'error',
        error: {
          code: 'NETWORK_ERROR',
          message: err instanceof Error ? err.message : 'Network error occurred',
          retryable: true
        }
      };
    }
  }

  logout(): void {
    this.state = { status: 'unauthenticated' };
  }
}

// UI レイヤーでの使用例
function renderAuthUI(state: AuthState): string {
  switch (state.status) {
    case 'unauthenticated':
      return '';
    case 'authenticating':
      return '';
    case 'authenticated':
      return ``;
    case 'error':
      return ``;
  }
}

// 厳密な型チェックの例
function getUserNameOrNull(state: AuthState): string | null {
  if (state.status === 'authenticated') {
    return state.user.name; // ここで state.user に安全にアクセス
  }
  return null;
}

function getErrorMessageOrNull(state: AuthState): string | null {
  if (state.status === 'error') {
    return state.error.message; // ここで state.error に安全にアクセス
  }
  return null;
}

よくある応用パターン

1. Discriminated Union(識別付きユニオン)

上の例で既に使われていますが、共通のプロパティ(この場合は status や type)を識別子として使い、その値に基づいて TypeScript が型を自動的に絞り込む(Narrowing)パターンです。これが Union Type を実務で強力にしている要因です。

type PaymentResult = 
  | { method: 'credit_card'; cardLastFour: string; approved: true }
  | { method: 'credit_card'; approved: false; reason: string }
  | { method: 'bank_transfer'; transferId: string; approved: true }
  | { method: 'bank_transfer'; approved: false; reason: string };

function processPayment(result: PaymentResult): void {
  // method でまず絞り込み
  if (result.method === 'credit_card') {
    if (result.approved) {
      console.log(`Credit card ending in ${result.cardLastFour} was approved`);
    } else {
      console.log(`Credit card rejected: ${result.reason}`);
    }
  } else {
    // ここで result は bank_transfer に絞り込まれている
    if (result.approved) {
      console.log(`Bank transfer ${result.transferId} was approved`);
    } else {
      console.log(`Bank transfer rejected: ${result.reason}`);
    }
  }
}

2. ジェネリック Union Type

汎用的な値を扱う際に、ジェネリクスと Union Type を組み合わせるパターンも重要です。

type Result<T, E = Error> = 
  | { success: true; value: T }
  | { success: false; error: E };

// 使用例
function parseJSON<T>(json: string): Result<T, string> {
  try {
    return { success: true, value: JSON.parse(json) };
  } catch (err) {
    return { success: false, error: 'Invalid JSON' };
  }
}

// 呼び出し
const result = parseJSON<{ name: string; age: number }>('{"name": "John", "age": 30}');
if (result.success) {
  console.log(result.value.name);
} else {
  console.error(result.error);
}

3. Union Type を使ったValidator パターン

入力値の検証結果を Union Type で表現するパターンも実務では頻繁です。

type ValidationResult<T> = 
  | { valid: true; data: T }
  | { valid: false; errors: Record<string, string[]> };

interface UserInput {
  email: string;
  password: string;
  name: string;
}

function validateUserInput(input: unknown): ValidationResult<UserInput> {
  const errors: Record<string, string[]> = {};

  if (typeof input !== 'object' || input === null) {
    return { valid: false, errors: { root: ['Input must be an object'] } };
  }

  const obj = input as Record<string, unknown>;

  // Email validation
  if (typeof obj.email !== 'string' || !obj.email.includes('@')) {
    errors.email = ['Email must be a valid email address'];
  }

  // Password validation
  if (typeof obj.password !== 'string' || obj.password.length < 8) {
    errors.password = ['Password must be at least 8 characters'];
  }

  // Name validation
  if (typeof obj.name !== 'string' || obj.name.trim().length === 0) {
    errors.name = ['Name cannot be empty'];
  }

  if (Object.keys(errors).length > 0) {
    return { valid: false, errors };
  }

  return {
    valid: true,
    data: {
      email: obj.email as string,
      password: obj.password as string,
      name: obj.name as string
    }
  };
}

// 使用例
const result = validateUserInput({
  email: 'user@example.com',
  password: 'secure123',
  name: 'John Doe'
});

if (result.valid) {
  console.log('User data:', result.data);
} else {
  console.error('Validation errors:', result.errors);
}

注意点と落とし穴

1. 型の過度な複雑化

Union Type は便利ですが、むやみに多くのパターンを追加すると、かえって保守性が低下します。特に switch 文でのパターンマッチングが大変になります。

// ❌ 悪い例:パターンが多すぎて管理が難しい
type Event = 
  | { type: 'EVENT_A'; payloadA: string }
  | { type: 'EVENT_B'; payloadB: number }
  | { type: 'EVENT_C'; payloadC: boolean }
  | { type: 'EVENT_D'; payloadD: { nested: string } }
  | { type: 'EVENT_E'; payloadE: string[] }
  // ... さらに多くのパターン

// ✅ 良い例:カテゴリで分類してから Union にする
type UserEvent = 
  | { type: 'LOGIN'; userId: number }
  | { type: 'LOGOUT'; userId: number };

type ProductEvent = 
  | { type: 'ADDED'; productId: number }
  | { type: 'REMOVED'; productId: number };

type AppEvent = UserEvent | ProductEvent;

2. never 型への到達不可能コード

Union Type の全パターンを網羅していない switch 文では、コンパイルエラーが出ません。意図的に未対応パターンを作らない限り、デフォルトケースで exhaustiveness check を行うべきです。

type Status = 'success' | 'error' | 'pending';

function handleStatus(status: Status): void {
  switch (status) {
    case 'success':
      console.log('Success');
      break;
    case 'error':
      console.log('Error');
      break;
    case 'pending':
      console.log('Pending');
      break;
    default:
      // exhaustiveness check
      const _exhaustive: never = status;
      throw new Error(`Unknown status: ${_exhaustive}`);
  }
}

3. 型ガード関数の必要性

Union Type の値に対して、型を確実に絞り込むために型ガード関数を使うことが重要です。特に複雑なオブジェクト型の場合です。

type ApiResponse = 
  | { status: 'success'; data: { id: number; name: string } }
  | { status: 'error'; errorCode: number; message: string };

// 型ガード関数
function isSuccessResponse(res: ApiResponse): res is { status: 'success'; data: { id: number; name: string } } {
  return res.status === 'success';
}

// 使用
function processResponse(res: ApiResponse): void {
  if (isSuccessResponse(res)) {
    console.log(res.data.name); // 安全に data にアクセス
  } else {
    console.error(`Error ${res.errorCode}: ${res.message}`);
  }
}

4. Union Type と optional の混在

Union Type と optional(? による undefined許容)を混ぜると、型チェックが複雑になります。明確に設計しましょう。

// ❌ 避けるべき:曖昧さが残る
type Response = { data?: string } | { error?: string };

// ✅ 推奨:明確に区分
type Response = 
  | { status: 'success'; data: string }
  | { status: 'error'; error: string };

まとめ

TypeScript の Union Type は、実務での型安全性を大幅に高める強力なツールです。特に以下のシーンで活躍します:

  • API レスポンスの型定義:成功・失敗・ローディングなど複数パターンを型レベルで管理
  • 状態管理:Redux や Zustand との相性が良く、状態遷移を安全に
  • イベント駆動:複数種類のイベントペイロードを統一的に扱う
  • バリデーション結果:成功時と失敗時で異なるデータ構造を表現

実装時のポイントは:

  • Discriminated Union を活用する:共通の識別プロパティで型を自動絞り込み
  • 型ガード関数を用いる:複雑な型では明示的にチェック
  • パターンを整理する:無秩序に増やさず、カテゴリで分類
  • exhaustiveness check を忘れない:全パターンの処理を確保

Union Type を正しく使いこなすことで、実行時エラーを事前に防ぎ、保守性の高いコードを書くことができます。本記事で紹介したパターンを参考に、皆さんのプロジェクトに適用してみてください。

タイトルとURLをコピーしました