TypeScript Conditional Type(条件付き型)を実務で活用する方法|実装パターンと応用例

TypeScript

TypeScript Conditional Type(条件付き型)を実務で活用する方法

TypeScriptのConditional Typeは、型定義時に条件分岐を行える機能です。本記事では、教科書的な説明ではなく、実務プロジェクトで実際に使われるコード例を中心に解説します。

1. Conditional Type とは?簡易解説

Conditional Typeは、ある型が別の型に代入可能かどうかで、型を使い分ける機能です。基本的な構文は次のようになります:

type IsString<T> = T extends string ? true : false;

type A = IsString<\"hello\">;  // true
type B = IsString<number>;   // false

この extends キーワードを使った条件判定により、ジェネリクス型を関数のオーバーロードのように柔軟に制御できます。

2. 業務でよく出会う Conditional Type のユースケース

2.1 API レスポンスの型の動的処理

実務では、API のレスポンス型が複数あり、そのタイプに応じて異なる処理が必要になることがよくあります。Conditional Type はこのような場面で活躍します。

例えば、ユーザー情報取得API とプロダクト情報取得APIがあり、成功時のレスポンス形式が異なる場合:

// API レスポンスの基本型定義
type ApiResponse<T> = {
  success: boolean;
  data: T;
  timestamp: number;
};

// ユーザー型
type User = {
  id: number;
  name: string;
  email: string;
  role: 'admin' | 'user';
};

// プロダクト型
type Product = {
  id: number;
  title: string;
  price: number;
  stock: number;
};

// Conditional Type で型に応じた処理を定義
type GetResponseData<T> = T extends ApiResponse<infer U> ? U : never;

type UserResponseData = GetResponseData<ApiResponse<User>>;  // User
type ProductResponseData = GetResponseData<ApiResponse<Product>>;  // Product

実際の使用例では、API からのレスポンスに応じて異なるログ出力や検証ロジックを実行する必要があります:

// 実務で使う関数の例
async function fetchAndProcess<T extends { endpoint: string }>(
  config: T
): Promise<T extends { endpoint: 'user' } ? User : Product> {
  const response = await fetch(`https://api.example.com/${config.endpoint}`);
  const data = await response.json();
  
  // 型に応じた処理
  if (config.endpoint === 'user') {
    // User 型の検証ロジック
    validateUser(data);
    return data as User;
  } else {
    // Product 型の検証ロジック
    validateProduct(data);
    return data as Product;
  }
}

// 使用例
const user = await fetchAndProcess({ endpoint: 'user' });
// user は User 型として推論される

const product = await fetchAndProcess({ endpoint: 'product' });
// product は Product 型として推論される

2.2 フォーム入力値の型検証

フォームの入力フィールドごとに異なる検証ルールが必要な場合、Conditional Type が役立ちます:

// フィールドの型定義
type FormFieldType = 'email' | 'password' | 'number' | 'text' | 'date';

// 型に応じた検証ルール
type ValidationRule<T extends FormFieldType> = 
  T extends 'email' ? { pattern: RegExp; minLength: number } :
  T extends 'password' ? { minLength: number; requireSpecialChar: boolean } :
  T extends 'number' ? { min: number; max: number } :
  T extends 'date' ? { minDate: Date; maxDate: Date } :
  { minLength: number };

// 実務で使う検証関数
function validateFormField<T extends FormFieldType>(
  fieldType: T,
  value: string,
  rule: ValidationRule<T>
): boolean {
  switch (fieldType) {
    case 'email':
      const emailRule = rule as ValidationRule<'email'>;
      return emailRule.pattern.test(value) && value.length >= emailRule.minLength;
    
    case 'password':
      const passwordRule = rule as ValidationRule<'password'>;
      const hasSpecialChar = /[!@#$%^&*]/.test(value);
      return value.length >= passwordRule.minLength && 
             (!passwordRule.requireSpecialChar || hasSpecialChar);
    
    case 'number':
      const numberRule = rule as ValidationRule<'number'>;
      const num = parseInt(value);
      return num >= numberRule.min && num <= numberRule.max;
    
    case 'date':
      const dateRule = rule as ValidationRule<'date'>;
      const date = new Date(value);
      return date >= dateRule.minDate && date <= dateRule.maxDate;
    
    default:
      return value.length >= (rule as ValidationRule<'text'>).minLength;
  }
}

// 使用例
const isEmailValid = validateFormField(
  'email',
  'user@example.com',
  { pattern: /@example\.com$/, minLength: 5 }
);

const isPasswordValid = validateFormField(
  'password',
  'Pass@word123',
  { minLength: 8, requireSpecialChar: true }
);

3. よくある応用パターン

3.1 キー制約付きのオブジェクト操作

実務では、特定の型のみを扱う関数が必要になることがあります。Conditional Type と keyof を組み合わせると、型安全なオブジェクト操作ができます:

// 実務例:データベースのフィールド更新
type UserModel = {
  id: number;
  name: string;
  email: string;
  createdAt: Date;
  updatedAt: Date;
};

// 更新可能なフィールドのみを指定する型
type UpdatableFields = Exclude<keyof UserModel, 'id' | 'createdAt'>;

type UpdatePayload<K extends UpdatableFields> = {
  field: K;
  value: UserModel[K];
};

// 実務で使う関数
async function updateUserField<K extends UpdatableFields>(
  userId: number,
  payload: UpdatePayload<K>
): Promise<void> {
  // SQL インジェクション対策済みのクエリ
  const query = `UPDATE users SET ${payload.field} = ? WHERE id = ?`;
  await database.execute(query, [payload.value, userId]);
}

// 使用例
await updateUserField(1, { field: 'name', value: 'John Doe' });
await updateUserField(1, { field: 'email', value: 'john@example.com' });

// これはコンパイルエラーになる(意図的に id は更新できない)
// await updateUserField(1, { field: 'id', value: 999 });

3.2 ネストされた型の抽出

複数階層のネストされたデータ構造から値を取り出す場合、Conditional Type を使うと便利です:

// 実務例:複雑な API レスポンス構造
type ApiResult = {
  status: 'success' | 'error';
  data: {
    user: {
      profile: {
        personalInfo: {
          firstName: string;
          lastName: string;
        };
      };
    };
  };
};

// ネストされた型から値を取り出す
type ExtractNested<T, K extends keyof any> = 
  T extends { [key in K]: infer U } ? U : never;

type FirstName = ExtractNested<ApiResult['data']['user']['profile']['personalInfo'], 'firstName'>;
// type FirstName = string

3.3 関数の戻り値の型の動的決定

引数の型に応じて戻り値の型を自動的に決定する関数は、実務で非常に価値があります:

// 実務例:キャッシュシステム
type CacheKey = 'user' | 'product' | 'settings';

type CacheValue<K extends CacheKey> = 
  K extends 'user' ? User :
  K extends 'product' ? Product :
  K extends 'settings' ? Record<string, unknown> :
  never;

class CacheManager {
  private cache = new Map<string, unknown>();

  get<K extends CacheKey>(key: K): CacheValue<K> | null {
    return (this.cache.get(key) as CacheValue<K>) || null;
  }

  set<K extends CacheKey>(key: K, value: CacheValue<K>): void {
    this.cache.set(key, value);
  }
}

// 使用例
const cacheManager = new CacheManager();

cacheManager.set('user', { id: 1, name: 'Alice', email: 'alice@example.com', role: 'admin' });
cacheManager.set('product', { id: 100, title: 'Widget', price: 99.99, stock: 50 });

const user = cacheManager.get('user');  // User | null
const product = cacheManager.get('product');  // Product | null
const settings = cacheManager.get('settings');  // Record<string, unknown> | null

4. Conditional Type を使う際の注意点

4.1 型推論の限界

Conditional Type は強力ですが、型推論が完全ではない場合があります。特に複雑な条件では明示的な型指定が必要です:

// 問題のあるパターン
type IsArrayOfStrings<T> = T extends string[] ? true : false;

type Result1 = IsArrayOfStrings<string[]>;  // true
type Result2 = IsArrayOfStrings<(string | number)[]>;  // false(予期しない結果)

// 解決策:より詳細な条件を指定
type IsArrayOfStringsOnly<T> = 
  T extends readonly string[] ? 
    T extends readonly (string | number)[] ? false : true 
  : false;

4.2 Distributive Conditional Types への理解

ユニオン型に対する Conditional Type は自動的に分配されます。これが意図しない動作につながることがあります:

// Distributive Conditional Type の例
type ToArray<T> = T extends any ? T[] : never;

type A = ToArray<string | number>;
// 結果:string[] | number[](各型が個別に配列化される)

// これを避ける場合は、T を配列でラップ
type ToArrayNonDistributive<T> = [T] extends [any] ? T[] : never;

type B = ToArrayNonDistributive<string | number>;
// 結果:(string | number)[](ユニオン全体が配列化される)

4.3 パフォーマンスの考慮

複雑に深いネストされた Conditional Type は、コンパイル時間を増加させる可能性があります。実務では、わかりやすさとパフォーマンスのバランスを取ることが重要です。

5. 実務で使えるテンプレート集

5.1 HTTP メソッド別の処理分岐

type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';

type RequestBody<M extends HttpMethod> = 
  M extends 'GET' | 'DELETE' ? undefined :
  M extends 'POST' | 'PUT' ? Record<string, unknown> :
  never;

type ResponseType<M extends HttpMethod> = 
  M extends 'GET' ? Record<string, unknown> :
  M extends 'POST' ? { id: string } :
  M extends 'PUT' ? Record<string, unknown> :
  M extends 'DELETE' ? { success: boolean } :
  never;

async function makeRequest<M extends HttpMethod>(
  method: M,
  url: string,
  body?: RequestBody<M>
): Promise<ResponseType<M>> {
  const response = await fetch(url, {
    method,
    body: body ? JSON.stringify(body) : undefined,
  });
  return response.json();
}

5.2 イベントハンドラーの型安全化

type EventType = 'click' | 'change' | 'submit' | 'focus';

type EventHandler<T extends EventType> = 
  T extends 'click' ? (event: MouseEvent) => void :
  T extends 'change' ? (event: Event) => void :
  T extends 'submit' ? (event: SubmitEvent) => void :
  T extends 'focus' ? (event: FocusEvent) => void :
  never;

function addEventListener<T extends EventType>(
  element: HTMLElement,
  type: T,
  handler: EventHandler<T>
): void {
  element.addEventListener(type, handler as EventListener);
}

6. まとめ

TypeScript の Conditional Type は、単なる型定義の機能ではなく、実務における型安全性の向上とコード保守性の改善に直結する重要な機能です。

本記事で紹介したパターン:

  • API レスポンスの動的型処理:複数の API エンドポイントを統一的に扱える
  • フォーム検証:フィールド種別に応じた安全な検証ロジック
  • キー制約付きのオブジェクト操作:不正な更新を型レベルで防止
  • キャッシュシステム:型推論による自動的な戻り値型の決定

これらは実務プロジェクトで実際に使われているパターンです。最初は複雑に見えるかもしれませんが、チーム内で同じパターンを使い回すことで、開発効率とバグ予防につながります。

重要な注意点として、Conditional Type は強力ですが可読性とのバランスが必要です。チームメンバーが理解できる範囲内で、無理なく導入することをお勧めします。また、複数の条件が必要な場合は、複数の Conditional Type を組み合わせるか、ヘルパー型を作成して管理しやすくしましょう。

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