TypeScript Genericsの実務活用ガイド|型安全性と再利用性を両立する実装パターン

TypeScript

TypeScript Genericsの実務活用ガイド|型安全性と再利用性を両立する実装パターン

多くのTypeScriptプロジェクトでGenericsは「難しい概念」として避けられる傾向があります。しかし実務では、Genericsを活用することで驚くほどコードの品質と保守性が向上します。本記事では、教科書的な説明ではなく、実際のプロジェクトで今日から使えるGenericsのパターンを紹介します。

1. Genericsの簡易的な解説

Genericsは「型をパラメータ化する」仕組みです。関数やクラスの定義時に具体的な型を決めず、呼び出し時に型を指定することで、同じコードを複数の型に対応させられます。

基本的な構文は以下の通りです:

// 基本的なGeneric関数
function identity<T>(value: T): T {
  return value;
}

const result1 = identity<string>('hello'); // stringとして推論
const result2 = identity<number>(42); // numberとして推論

ここで<T>は「型変数」と呼ばれ、呼び出し時に具体的な型に置き換わります。Tは単なる慣例で、わかりやすい名前を使っても問題ありません。

2. 業務でのユースケース

実務では、Genericsは以下のような場面で活躍します:

  • APIレスポンス処理:異なるスキーマのレスポンスを統一的に処理
  • データベース操作:複数のテーブルに対応したDAO/Repository層
  • ページネーション:どんなデータ型にも対応したページャー
  • キャッシュ機構:任意の型をキャッシュできる汎用的なシステム
  • フォームバリデーション:型安全な入力値検証

これらの場面では、Genericsを使わないとコードの重複が増えたり、型安全性が失われたりします。

3. 実装コード|実務レベルのパターン

パターン1:APIレスポンスハンドリング

最も一般的なユースケースがAPIレスポンスの処理です。以下は、成功・エラーの両ケースに対応した実装例です:

// APIレスポンスの型定義
interface ApiResponse<T> {
  status: 'success' | 'error';
  data?: T;
  error?: {
    code: string;
    message: string;
  };
  timestamp: number;
}

// ユーザーデータの型
interface User {
  id: number;
  name: string;
  email: string;
  createdAt: string;
}

// ページネーション付きのレスポンス
interface PaginatedResponse<T> {
  items: T[];
  total: number;
  page: number;
  pageSize: number;
  hasMore: boolean;
}

// APIレスポンスの結合型
type UserListResponse = ApiResponse<PaginatedResponse<User>>;

// レスポンスを処理する汎用ハンドラー
async function handleApiResponse<T>(
  response: Response
): Promise<ApiResponse<T>> {
  if (!response.ok) {
    const errorData = await response.json();
    throw new Error(
      `API Error: ${errorData.error?.message || 'Unknown error'}`
    );
  }

  const data: ApiResponse<T> = await response.json();

  if (data.status === 'error') {
    throw new Error(`Business Error: ${data.error?.message}`);
  }

  return data;
}

// 実際の使用例
async function fetchUsers(page: number): Promise<User[]> {
  const response = await fetch(`/api/users?page=${page}`);
  const result = await handleApiResponse<PaginatedResponse<User>>(
    response
  );

  // resultはApiResponse<PaginatedResponse<User>>として型推論される
  if (result.data) {
    return result.data.items;
  }

  return [];
}

このパターンの利点は、異なるエンドポイント(ユーザー、商品、注文など)に対して同じ処理ロジックを再利用できることです。型安全性も保証されます。

パターン2:Repository パターンによるデータアクセス

バックエンドやORM操作の抽象化に非常に有効です:

// 基本的なRepository インターフェース
interface IRepository<T, ID> {
  findById(id: ID): Promise<T | null>;
  findAll(): Promise<T[]>;
  create(data: Omit<T, 'id'>): Promise<T>;
  update(id: ID, data: Partial<T>): Promise<T>;
  delete(id: ID): Promise<boolean>;
}

// エンティティ定義
interface Product {
  id: number;
  name: string;
  price: number;
  stock: number;
  categoryId: number;
  createdAt: Date;
}

interface Order {
  id: number;
  userId: number;
  totalAmount: number;
  status: 'pending' | 'completed' | 'cancelled';
  createdAt: Date;
}

// 実装例(SQLiteベース)
class SqliteRepository<T extends { id: number }> implements IRepository<T, number> {
  constructor(private tableName: string, private db: Database) {}

  async findById(id: number): Promise<T | null> {
    const sql = `SELECT * FROM ${this.tableName} WHERE id = ?`;
    const result = this.db.prepare(sql).get(id) as T | undefined;
    return result || null;
  }

  async findAll(): Promise<T[]> {
    const sql = `SELECT * FROM ${this.tableName}`;
    const results = this.db.prepare(sql).all() as T[];
    return results;
  }

  async create(data: Omit<T, 'id'>): Promise<T> {
    const keys = Object.keys(data);
    const values = Object.values(data);
    const placeholders = keys.map(() => '?').join(', ');

    const sql = `
      INSERT INTO ${this.tableName} (${keys.join(', ')})
      VALUES (${placeholders})
    `;

    const result = this.db.prepare(sql).run(...values);
    const createdRecord = await this.findById(result.lastID as number);

    if (!createdRecord) {
      throw new Error('Failed to create record');
    }

    return createdRecord;
  }

  async update(id: number, data: Partial<T>): Promise<T> {
    const keys = Object.keys(data);
    const values = Object.values(data);
    const setClause = keys.map(key => `${key} = ?`).join(', ');

    const sql = `
      UPDATE ${this.tableName}
      SET ${setClause}
      WHERE id = ?
    `;

    this.db.prepare(sql).run(...values, id);
    const updated = await this.findById(id);

    if (!updated) {
      throw new Error('Record not found');
    }

    return updated;
  }

  async delete(id: number): Promise<boolean> {
    const sql = `DELETE FROM ${this.tableName} WHERE id = ?`;
    const result = this.db.prepare(sql).run(id);
    return result.changes > 0;
  }
}

// 使用例
class ProductService {
  private productRepository: IRepository<Product, number>;
  private orderRepository: IRepository<Order, number>;

  constructor(db: Database) {
    this.productRepository = new SqliteRepository<Product>('products', db);
    this.orderRepository = new SqliteRepository<Order>('orders', db);
  }

  async getProductById(id: number): Promise<Product | null> {
    return this.productRepository.findById(id);
  }

  async getAllProducts(): Promise<Product[]> {
    return this.productRepository.findAll();
  }

  async updateProductStock(id: number, newStock: number): Promise<Product> {
    return this.productRepository.update(id, { stock: newStock } as Partial<Product>);
  }

  async createOrder(userId: number, totalAmount: number): Promise<Order> {
    return this.orderRepository.create({
      userId,
      totalAmount,
      status: 'pending',
      createdAt: new Date(),
    });
  }
}

このパターンにより、異なるテーブルに対して同じロジックを適用できます。新しいエンティティを追加する際、Repositoryの実装を繰り返す必要がありません。

パターン3:キャッシュ機構

任意の型の値をキャッシュできる汎用的なシステムの実装例:

interface CacheOptions {
  ttl?: number; // ミリ秒
  maxSize?: number;
}

interface CacheEntry<T> {
  value: T;
  expiresAt?: number;
  accessCount: number;
  lastAccessed: number;
}

class Cache<T> {
  private store = new Map<string, CacheEntry<T>>();
  private options: Required<CacheOptions>;

  constructor(options: CacheOptions = {}) {
    this.options = {
      ttl: options.ttl || 60000, // デフォルト60秒
      maxSize: options.maxSize || 100,
    };
  }

  set(key: string, value: T): void {
    // サイズ制限チェック(LRU削除)
    if (
      this.store.size >= this.options.maxSize &&
      !this.store.has(key)
    ) {
      let lruKey = '';
      let minAccess = Infinity;

      for (const [k, entry] of this.store) {
        if (entry.lastAccessed < minAccess) {
          minAccess = entry.lastAccessed;
          lruKey = k;
        }
      }

      if (lruKey) {
        this.store.delete(lruKey);
      }
    }

    const expiresAt = Date.now() + this.options.ttl;
    this.store.set(key, {
      value,
      expiresAt,
      accessCount: 0,
      lastAccessed: Date.now(),
    });
  }

  get(key: string): T | null {
    const entry = this.store.get(key);

    if (!entry) {
      return null;
    }

    // TTLチェック
    if (entry.expiresAt && Date.now() > entry.expiresAt) {
      this.store.delete(key);
      return null;
    }

    // メタデータ更新
    entry.accessCount++;
    entry.lastAccessed = Date.now();

    return entry.value;
  }

  has(key: string): boolean {
    return this.get(key) !== null;
  }

  delete(key: string): boolean {
    return this.store.delete(key);
  }

  clear(): void {
    this.store.clear();
  }

  getStats(key: string): CacheEntry<T> | null {
    return this.store.get(key) || null;
  }
}

// 実装例:APIレスポンスのキャッシング
class CachedApiClient {
  private cache: Cache<ApiResponse<unknown>>;

  constructor() {
    this.cache = new Cache({ ttl: 300000, maxSize: 50 }); // 5分、最大50件
  }

  async fetch<T>(url: string): Promise<T> {
    const cacheKey = `GET:${url}`;

    // キャッシュがあれば返す
    const cached = this.cache.get(cacheKey);
    if (cached && cached.data) {
      console.log(`Cache hit: ${cacheKey}`);
      return cached.data as T;
    }

    // キャッシュなければリクエスト
    const response = await fetch(url);
    const data = (await handleApiResponse<T>(response)) as ApiResponse<unknown>;

    // キャッシュに保存
    this.cache.set(cacheKey, data);

    if (data.data) {
      return data.data as T;
    }

    throw new Error('No data in response');
  }
}

// 使用例
const client = new CachedApiClient();
const users = await client.fetch<User[]>('/api/users');

パターン4:条件付きGenericとリジッドなバリデーション

より複雑な実務では、Genericsの制約(constraint)を活用します:

// Key-Valueペアの制約付きGeneric
interface KeyValue {
  [key: string]: unknown;
}

// T は KeyValue を拡張する必要がある
class FormValidator<T extends KeyValue> {
  private rules: Map<keyof T, Array<(value: unknown) => string | null>>;

  constructor() {
    this.rules = new Map();
  }

  addRule(
    field: keyof T,
    validator: (value: unknown) => string | null
  ): void {
    if (!this.rules.has(field)) {
      this.rules.set(field, []);
    }
    this.rules.get(field)!.push(validator);
  }

  validate(data: T): { valid: boolean; errors: Partial<Record<keyof T, string[]>> } {
    const errors: Partial<Record<keyof T, string[]>> = {};
    let valid = true;

    for (const [field, validators] of this.rules) {
      const fieldErrors: string[] = [];

      for (const validator of validators) {
        const value = data[field];
        const error = validator(value);

        if (error) {
          fieldErrors.push(error);
          valid = false;
        }
      }

      if (fieldErrors.length > 0) {
        errors[field] = fieldErrors;
      }
    }

    return { valid, errors };
  }
}

// フォームデータ定義
interface RegistrationForm {
  username: string;
  email: string;
  password: string;
  age: number;
  terms: boolean;
}

// 使用例
const validator = new FormValidator<RegistrationForm>();

validator.addRule('username', (value) => {
  const str = value as string;
  return str.length < 3 ? 'ユーザー名は3文字以上必要です' : null;
});

validator.addRule('email', (value) => {
  const str = value as string;
  const emailRegex = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;
  return !emailRegex.test(str) ? 'メールアドレスが無効です' : null;
});

validator.addRule('password', (value) => {
  const str = value as string;
  return str.length < 8 ? 'パスワードは8文字以上必要です' : null;
});

validator.addRule('age', (value) => {
  const num = value as number;
  return num < 18 ? '18歳以上である必要があります' : null;
});

validator.addRule('terms', (value) => {
  return !value ? '利用規約に同意する必要があります' : null;
});

// 検証実行
const formData: RegistrationForm = {
  username: 'ab',
  email: 'invalid-email',
  password: 'short',
  age: 16,
  terms: false,
};

const result = validator.validate(formData);
console.log(result.valid); // false
console.log(result.errors); // 各フィールドのエラーメッセージ

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

Union型を使った柔軟なAPI設計

// 複数の型を許可するGeneric
type ApiResult<Success, Error = string> = 
  | { ok: true; data: Success }
  | { ok: false; error: Error };

async function safeApiCall<T, E extends { code: string; message: string }>(
  fn: () => Promise<T>,
  errorHandler?: (err: unknown) => E
): Promise<ApiResult<T, E>> {
  try {
    const data = await fn();
    return { ok: true, data };
  } catch (err) {
    const error = errorHandler
      ? errorHandler(err)
      : ({
          code: 'UNKNOWN_ERROR',
          message: String(err),
        } as E);
    return { ok: false, error };
  }
}

// 使用例
const result = await safeApiCall(
  () => fetchUsers(1),
  (err) => ({
    code: 'FETCH_ERROR',
    message: `ユーザー取得に失敗しました: ${err}`,
  })
);

if (result.ok) {
  console.log('ユーザー:', result.data);
} else {
  console.error('エラー:', result.error.message);
}

配列を扱うユーティリティ関数

// 汎用的なグループ化関数
function groupBy<T, K extends string | number | symbol>(
  items: T[],
  keyFn: (item: T) => K
): Record<K, T[]> {
  return items.reduce(
    (acc, item) => {
      const key = keyFn(item);
      if (!acc[key]) {
        acc[key] = [];
      }
      acc[key].push(item);
      return acc;
    },
    {} as Record<K, T[]>
  );
}

// ページネーション
function paginate<T>(
  items: T[],
  page: number,
  pageSize: number
): { items: T[]; total: number; page: number; pageSize: number; totalPages: number } {
  const total = items.length;
  const totalPages = Math.ceil(total / pageSize);
  const start = (page - 1) * pageSize;
  const end = start + pageSize;

  return {
    items: items.slice(start, end),
    total,
    page,
    pageSize,
    totalPages,
  };
}

// 使用例
const users: User[] = [
  { id: 1, name: 'Alice', email: 'alice@example.com', createdAt: '2024-01-01' },
  { id: 2, name: 'Bob', email: 'bob@example.com', createdAt: '2024-01-02' },
  // ... more users
];

const grouped = groupBy(users, (user) => user.email.split('@')[1]);
console.log(grouped); // { 'example.com': [...], ... }

const page1 = paginate(users, 1, 10);
console.log(page1.items); // 最初の10件

5. よくある落とし穴と注意点

注意点1:型の過度な複雑化

Genericsを使いすぎると、かえってコードが読みづらくなります:

// ❌ 悪い例:過度に複雑
type ComplexType<T extends { id: string }, U extends Record<string, T>, V extends keyof U> = U[V];

// ✅ 良い例:明確な責任
type EntityMap<T extends { id: string }> = Record<string, T>;
type GetEntityById<T extends { id: string }> = (map: EntityMap<T>, id: string) => T | undefined;

注意点2:ランタイムでのGenericの消失

TypeScriptはコンパイル時のみGenericが利用できます。ランタイムでは型情報は消失します:

// ❌ 動作しない
function isType<T>(value: unknown): value is T {
  // ランタイムではTの情報が利用できない
  return typeof value === 'object'; // これだけでは不十分
}

// ✅ 正しい方法
function isType<T>(
  value: unknown,
  typeGuard: (value: unknown) => value is T
): value is T {
  return typeGuard(value);
}

// または、判別用プロパティを使う
interface TypedData<T> {
  type: string;
  data: T;
}

function isTypedData<T>(
  value: unknown,
  expectedType: string
): value is TypedData<T> {
  return (
    typeof value === 'object' &&
    value !== null &&
    'type' in value &&
    value.type === expectedType
  );
}

注意点3:デフォルト型パラメータの設定

// ❌ 毎回型を指定する必要がある
function request<T>(url: string): Promise<T> {
  // ...
}

const data = await request<User>('/api/users'); // 毎回指定必須

// ✅ デフォルト値を設定
function request<T = unknown>(url: string): Promise<T> {
  // ...
}

const data1 = await request('/api/users'); // T = unknown
const data2 = await request<User>('/api/users'); // T = User に明示的に変更可能

注意点4:配列と「in」を使った制約チェック

// ❌ 実行時エラーのリスク
function getProperty<T, K extends string>(obj: T, key: K): unknown {
  return obj[key as keyof T]; // キャストが必要で危険
}

// ✅ 安全な実装
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key]; // キャスト不要で型安全
}

const user = { id: 1, name: 'Alice' };
const id = getProperty(user, 'id'); // OK: idは存在
// const invalid = getProperty(user, 'age'); // エラー:ageは存在しない

6. 実務での導入Tips

段階的な導入

Genericsを導入する際は、以下の優先順位をお勧めします:

  1. APIレスポンス処理:最も効果が高く、導入も容易
  2. Repository/DAO層:データベース操作を抽象化してからGenericを導入
  3. ユーティリティ関数:配列処理やフィルタリング関数
  4. 複雑な型安全:フォームバリデーションなど、複雑な要件に対応

チーム内での統一

Genericsの使用パターンをドキュメント化し、チーム内で統一することが重要です。プロジェクト初期段階で以下を決めておくと良いでしょう:

  • 型変数の命名規則(T, U, V、またはより説明的な名前か)
  • Generic制約の統一的な書き方
  • 複雑すぎる型は避け、シンプルさを重視

7. まとめ

TypeScript Genericsは、単なる型システムの機能ではなく、実務でのコード品質向上、保守性向上、重複排除に直結する重要なツールです。

実務での活用ポイント:

  • APIレスポンス処理で使う:即座にコードの質が向上する
  • Repository パターンで使う:複数のエンティティに対応でき、スケーラビリティが向上
  • キャッシュなど汎用機構で使う:型安全性を保ちながら再利用性を確保
  • 複雑すぎないレベルに抑える:チーム全体が理解できることが最優先
  • ランタイムと型時間の違いを理解:型消失を考慮した設計

本記事で紹介したパターンは、実際のプロジェクトで今日から使える実装例ばかりです。最初は APIレスポンス処理から始め、段階的にGenericsの活用を広げていくことをお勧めします。型安全性と再利用性の向上により、バグが減り、保守がしやすいコードベースが実現します。

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