TypeScript Genericsの実践的な使い方|業務で役立つ実装パターン集
\n\n
はじめに:Genericsとは何か
\n
TypeScriptのGenerics(ジェネリクス)は、型安全性を保ちながら再利用可能なコンポーネントを作成するための機能です。教科書的な説明では「型を引数として受け取る」という表現をされますが、実務では「異なるデータ型に対応するコンポーネントを1つのコードで実装する」ための実用的なツールです。
\n\n
実は多くの開発者がGenericsを敬遠する理由は、抽象的な説明に終始した記事が多いから。本記事では実際のプロジェクトで遭遇する問題を通じて、Genericsがいかに役立つかを示します。
\n
\n\n
業務でのユースケース:なぜGenericsが必要なのか
\n\n
Webアプリケーション開発では、複数の異なるAPIエンドポイントと連携する必要があります。ユーザー情報取得、商品一覧取得、注文履歴取得など、構造は異なるものの、「データを取得してレスポンスを処理する」という基本的な流れは同じです。
\n\n
Genericsなしでこれを実装すると、エンドポイントごとにほぼ同じコードを繰り返し書くことになり、保守性が低下します。Genericsを使えば、このパターンを一度だけ実装して、複数のデータ型に対応させることができます。
\n
\n\n
実装例1:API通信ラッパーのGenerics活用
\n\n
実務での最頻出パターンが、APIレスポンスの型安全な処理です。以下は、どのようなレスポンス型にも対応するAPI通信ラッパーの実装です。
\n\n
// APIレスポンスの共通構造を定義\ninterface ApiResponse<T> {\n success: boolean;\n data: T;\n message: string;\n timestamp: number;\n}\n\n// ユーザー情報の型定義\ninterface User {\n id: number;\n name: string;\n email: string;\n role: 'admin' | 'user';\n}\n\n// 商品情報の型定義\ninterface Product {\n id: number;\n title: string;\n price: number;\n inStock: boolean;\n}\n\n// 汎用API通信関数\nasync function fetchData<T>(endpoint: string): Promise<ApiResponse<T>> {\n try {\n const response = await fetch(endpoint);\n \n if (!response.ok) {\n throw new Error(`HTTP error! status: ${response.status}`);\n }\n \n const json = await response.json() as ApiResponse<T>;\n return json;\n } catch (error) {\n return {\n success: false,\n data: null as any,\n message: `Failed to fetch from ${endpoint}: ${error instanceof Error ? error.message : 'Unknown error'}`,\n timestamp: Date.now()\n };\n }\n}\n\n// 使用例\nconst userResponse = await fetchData<User>('/api/users/1');\nif (userResponse.success) {\n // TypeScriptは自動的にuserResponse.dataをUser型として認識\n console.log(userResponse.data.name); // エラーなし\n console.log(userResponse.data.role); // エラーなし\n // console.log(userResponse.data.price); // エラー:Userには存在しないプロパティ\n}\n\nconst productResponse = await fetchData<Product>('/api/products/1');\nif (productResponse.success) {\n console.log(productResponse.data.title);\n console.log(productResponse.data.price);\n}\n
\n\n
このパターンの利点は明らかです。fetchData関数を一度だけ実装すれば、User、Product、またはあらゆる型に対応できます。また、各型の使用箇所でTypeScriptの自動補完が効くため、入力ミスを防げます。
\n
\n\n
実装例2:リポジトリパターンとGenerics
\n\n
データベースアクセスをラップするリポジトリパターンでも、Genericsは大活躍します。複数のテーブルに対して統一的なCRUD操作を提供する場合を見てみましょう。
\n\n
// 基本的なエンティティインターフェース\ninterface BaseEntity {\n id: number;\n createdAt: Date;\n updatedAt: Date;\n}\n\ninterface UserEntity extends BaseEntity {\n name: string;\n email: string;\n password_hash: string;\n}\n\ninterface OrderEntity extends BaseEntity {\n user_id: number;\n total_amount: number;\n status: 'pending' | 'completed' | 'cancelled';\n}\n\n// 汎用リポジトリクラス\nclass Repository<T extends BaseEntity> {\n private tableName: string;\n private db: any; // 実際にはデータベースコネクション\n\n constructor(tableName: string, db: any) {\n this.tableName = tableName;\n this.db = db;\n }\n\n async findById(id: number): Promise<T | null> {\n const query = `SELECT * FROM ${this.tableName} WHERE id = ?`;\n const result = await this.db.query(query, [id]);\n return result.length > 0 ? (result[0] as T) : null;\n }\n\n async findAll(limit: number = 100): Promise<T[]> {\n const query = `SELECT * FROM ${this.tableName} LIMIT ?`;\n return await this.db.query(query, [limit]) as T[];\n }\n\n async create(entity: Omit<T, 'id' | 'createdAt' | 'updatedAt'>): Promise<T> {\n const keys = Object.keys(entity);\n const values = Object.values(entity);\n const placeholders = keys.map(() => '?').join(', ');\n const query = `INSERT INTO ${this.tableName} (${keys.join(', ')}) VALUES (${placeholders})`;\n \n const result = await this.db.query(query, values);\n return {\n ...entity,\n id: result.insertId,\n createdAt: new Date(),\n updatedAt: new Date()\n } as T;\n }\n\n async update(id: number, partial: Partial<Omit<T, 'id'>>): Promise<T | null> {\n const keys = Object.keys(partial);\n const values = [...Object.values(partial), id];\n const setClause = keys.map(key => `${key} = ?`).join(', ');\n const query = `UPDATE ${this.tableName} SET ${setClause}, updatedAt = NOW() WHERE id = ?`;\n \n await this.db.query(query, values);\n return this.findById(id);\n }\n\n async delete(id: number): Promise<boolean> {\n const query = `DELETE FROM ${this.tableName} WHERE id = ?`;\n const result = await this.db.query(query, [id]);\n return result.affectedRows > 0;\n }\n\n async findWhere(conditions: Partial<T>, limit: number = 100): Promise<T[]> {\n const keys = Object.keys(conditions);\n const values = Object.values(conditions);\n const whereClause = keys.map(key => `${key} = ?`).join(' AND ');\n const query = `SELECT * FROM ${this.tableName} WHERE ${whereClause} LIMIT ?`;\n \n return await this.db.query(query, [...values, limit]) as T[];\n }\n}\n\n// 使用例\nconst userRepository = new Repository<UserEntity>('users', database);\nconst user = await userRepository.findById(1);\nif (user) {\n // user.passwordはUserEntity型から自動認識される\n console.log(user.email);\n}\n\n// 部分更新\nconst updatedUser = await userRepository.update(1, {\n email: 'newemail@example.com'\n});\n\nconst orderRepository = new Repository<OrderEntity>('orders', database);\nconst pendingOrders = await orderRepository.findWhere({ status: 'pending' });\n// pendingOrders内の各要素はOrderEntity型として認識される\npendingOrders.forEach(order => {\n console.log(order.total_amount);\n});\n
\n\n
このリポジトリパターンの実装により、新しいエンティティを追加する際は単に型を定義してRepositoryをインスタンス化するだけで済みます。find、create、update、deleteの実装を何度も繰り返す手間が省けます。
\n
\n\n
実装例3:フェッチとキャッシュを統合した高度なパターン
\n\n
実務では、単純なフェッチだけでなく、結果をキャッシュして重複リクエストを防ぐ必要があります。以下は、Genericsを活用したキャッシング機能付きのデータフェッチャーです。
\n\n
interface CacheEntry<T> {\n data: T;\n timestamp: number;\n ttl: number; // Time to live in milliseconds\n}\n\ntype DataFetcher<T> = () => Promise<T>;\ntype DataTransformer<T, U> = (data: T) => U;\n\nclass CachedDataFetcher<T> {\n private cache = new Map<string, CacheEntry<T>>();\n private pendingRequests = new Map<string, Promise<T>>();\n private defaultTtl = 5 * 60 * 1000; // 5分\n\n async fetch(key: string, fetcher: DataFetcher<T>, ttl?: number): Promise<T> {\n // キャッシュが有効な場合はそれを返す\n const cached = this.cache.get(key);\n if (cached && Date.now() - cached.timestamp < cached.ttl) {\n return cached.data;\n }\n\n // 既に進行中のリクエストがある場合はそれを待つ\n if (this.pendingRequests.has(key)) {\n return this.pendingRequests.get(key)!;\n }\n\n // 新しいリクエストを開始\n const promise = (async () => {\n try {\n const data = await fetcher();\n this.cache.set(key, {\n data,\n timestamp: Date.now(),\n ttl: ttl ?? this.defaultTtl\n });\n return data;\n } finally {\n this.pendingRequests.delete(key);\n }\n })();\n\n this.pendingRequests.set(key, promise);\n return promise;\n }\n\n invalidate(key: string): void {\n this.cache.delete(key);\n }\n\n invalidateAll(): void {\n this.cache.clear();\n }\n}\n\n// 使用例\ninterface UserProfile {\n id: number;\n name: string;\n email: string;\n avatar_url: string;\n}\n\nconst userFetcher = new CachedDataFetcher<UserProfile>();\n\n// ユーザー情報をフェッチ、またはキャッシュから取得\nconst userProfile = await userFetcher.fetch(\n `user:${userId}`,\n async () => {\n const response = await fetch(`/api/users/${userId}`);\n return response.json() as Promise<UserProfile>;\n },\n 10 * 60 * 1000 // 10分のTTL\n);\n\nconsole.log(userProfile.name); // UserProfile型として自動補完される\n
\n\n
このパターンにより、複数の場所からユーザー情報を取得する場合、不要な重複リクエストが自動的に排除され、パフォーマンスが向上します。
\n
\n\n
実装例4:条件付き型とGenericsの組み合わせ
\n\n
より高度な実務パターンとして、条件付き型(Conditional Types)とGenericsを組み合わせる場合があります。以下は、ペイロードの型に応じて戻り値の型が変わるイベントディスパッチャーです。
\n\n
// イベントマップ:イベント名とペイロード型の対応を定義\ninterface EventMap {\n 'user:login': { userId: number; timestamp: number };\n 'user:logout': { userId: number };\n 'product:purchase': { productId: number; quantity: number; price: number };\n 'cart:update': { items: Array<{ productId: number; quantity: number }> };\n}\n\ntype EventHandler<K extends keyof EventMap> = (payload: EventMap[K]) => void | Promise<void>;\n\nclass TypeSafeEventEmitter {\n private handlers = new Map<string, EventHandler<any>[]>();\n\n // リスナーを登録\n on<K extends keyof EventMap>(event: K, handler: EventHandler<K>): void {\n if (!this.handlers.has(event as string)) {\n this.handlers.set(event as string, []);\n }\n this.handlers.get(event as string)!.push(handler);\n }\n\n // イベントを発火\n async emit<K extends keyof EventMap>(event: K, payload: EventMap[K]): Promise<void> {\n const eventHandlers = this.handlers.get(event as string) || [];\n await Promise.all(eventHandlers.map(handler => handler(payload)));\n }\n\n // リスナーを削除\n off<K extends keyof EventMap>(event: K, handler: EventHandler<K>): void {\n const handlers = this.handlers.get(event as string) || [];\n const index = handlers.indexOf(handler);\n if (index !== -1) {\n handlers.splice(index, 1);\n }\n }\n}\n\n// 使用例\nconst emitter = new TypeSafeEventEmitter();\n\n// ハンドラーの引数型は自動的に推論される\nemitter.on('user:login', (payload) => {\n // payloadは { userId: number; timestamp: number } 型\n console.log(`User ${payload.userId} logged in at ${payload.timestamp}`);\n // console.log(payload.price); // エラー:user:loginペイロードにpriceは存在しない\n});\n\nemitter.on('product:purchase', (payload) => {\n // payloadは { productId: number; quantity: number; price: number } 型\n console.log(`Purchased product ${payload.productId} x${payload.quantity} for $${payload.price}`);\n});\n\n// 発火時もペイロード型がチェックされる\nawait emitter.emit('user:login', {\n userId: 123,\n timestamp: Date.now()\n});\n\n// 以下はコンパイルエラー\n// await emitter.emit('user:login', { userId: 123 }); // エラー:timestampが足りない\n// await emitter.emit('user:login', { userId: '123', timestamp: Date.now() }); // エラー:userIdが文字列\n
\n\n
このパターンは、複数のイベント型を扱う大規模なアプリケーションで特に有用です。イベント名とペイロード型の対応を一元管理でき、誤ったペイロードの使用をコンパイル時に検出できます。
\n
\n\n
Pythonの比較:Genericsの相互運用性
\n\n
TypeScriptのバックエンドがPythonで実装されている場合、PythonのGenericsと相互運用する場面もあります。以下はPythonでの同等の実装例です。
\n\n
from typing import TypeVar, Generic, Optional, List, Type, Any\nfrom dataclasses import dataclass\nfrom datetime import datetime\nimport json\n\nT = TypeVar('T')\n\n@dataclass\nclass ApiResponse(Generic[T]):\n success: bool\n data: Optional[T]\n message: str\n timestamp: float\n\n@dataclass\nclass User:\n id: int\n name: str\n email: str\n role: str\n\n@dataclass\nclass Product:\n id: int\n title: str\n price: float\n in_stock: bool\n\nclass Repository(Generic[T]):\n def __init__(self, table_name: str):\n self.table_name = table_name\n\n def find_by_id(self, id: int) -> Optional[T]:\n # 実装: データベースからTのインスタンスを取得\n pass\n\n def find_all(self, limit: int = 100) -> List[T]:\n # 実装: データベースからTのリストを取得\n pass\n\n def create(self, entity: Any) -> T:\n # 実装: 新しいTのインスタンスを作成\n pass\n\n# 使用例\nuser_repo: Repository[User] = Repository[User]('users')\nusers: List[User] = user_repo.find_all()\n\nproduct_repo: Repository[Product] = Repository[Product]('products')\nproducts: List[Product] = product_repo.find_all()\n
\n\n
PythonではRuntime時に型情報が失われるため、TypeScriptほどの厳密な型チェックは期待できません。ただし、型ヒントとしての価値は十分にあり、IDEの自動補完やmypyなどの静的型チェッカーで活用できます。
\n
\n\n
よくある応用パターン
\n\n
パターン1:Union型とGenericsの組み合わせ
\n\n
// 複数の成功パターンを持つレスポンス\ntype SuccessResponse<T> = {\n status: 'success';\n data: T;\n};\n\ntype ErrorResponse = {\n status: 'error';\n error: string;\n code: number;\n};\n\ntype ApiResult<T> = SuccessResponse<T> | ErrorResponse;\n\nasync function handleResponse<T>(result: ApiResult<T>): void {\n if (result.status === 'success') {\n console.log(result.data); // Tの型\n } else {\n console.log(result.error); // errorプロパティは常に利用可能\n }\n}\n
\n\n
パターン2:配列操作の汎用化
\n\n
class CollectionHelper<T> {\n constructor(private items: T[]) {}\n\n // 条件に基づいて分割\n partition(predicate: (item: T) => boolean): [T[], T[]] {\n const trueItems: T[] = [];\n const falseItems: T[] = [];\n\n this.items.forEach(item => {\n if (predicate(item)) {\n trueItems.push(item);\n } else {\n falseItems.push(item);\n }\n });\n\n return [trueItems, falseItems];\n }\n\n // 重複を削除\n unique<K extends keyof T>(key: K): T[] {\n const seen = new Set<T[K]>();\n return this.items.filter(item => {\n const value = item[key];\n if (seen.has(value)) {\n return false;\n }\n seen.add(value);\n return true;\n });\n }\n\n // グループ化\n groupBy<K extends string | number>(keySelector: (item: T) => K): Map<K, T[]> {\n const map = new Map<K, T[]>();\n\n this.items.forEach(item => {\n const key = keySelector(item);\n if (!map.has(key)) {\n map.set(key, []);\n }\n map.get(key)!.push(item);\n });\n\n return map;\n }\n}\n\n// 使用例\ninterface Order {\n id: number;\n customerId: number;\n amount: number;\n status: 'completed' | 'pending';\n}\n\nconst orders: Order[] = [/* ... */];\nconst helper = new CollectionHelper(orders);\n\nconst [completed, pending] = helper.partition(order => order.status === 'completed');\nconst uniqueCustomers = helper.unique('customerId');\nconst byStatus = helper.groupBy(order => order.status);\n
\n
\n\n
実務での注意点
\n\n
1. 過度な一般化を避ける
\n\n
Genericsの力に魅了され、すべてを汎用化しようとするのは避けるべきです。特定のドメインロジックは、むしろ具体的な型で表現する方が読みやすく保守しやすいことが多いです。
\n\n
// 過度に一般化された悪い例\nclass Processor<T, U, V, W extends BaseType<U>> {\n // ...\n}\n\n// より実用的で読みやすい例\nclass UserProcessor {\n async processUserData(user: User): Promise<ProcessedUser> {\n // 具体的なロジック\n }\n}\n
\n\n
2. any型の使用を避ける
\n\n
Genericsを使うメリットは型安全性にあります。any型を使うことで、その利点を失ってしまいます。
\n\n
// 悪い例\nclass ApiClient {\n async fetch<T>(endpoint: string): Promise<any> { // any型は避ける\n // ...\n }\n}\n\n// 良い例\nclass ApiClient {\n async fetch<T>(endpoint: string): Promise<T> { // 適切にGenericsを使用\n // ...\n }\n}\n
\n\n
3. 型の制約を活用する
\n\n
Genericsに制約を設けることで、コンパイル時に不正な使用を防げます。
\n\n
// 制約なし:任意の型T\nfunction processData<T>(data: T): void { }\n\n// 制約あり:Tはstringプロパティを持つ必要がある\nfunction processData<T extends { name: string }>(data: T): void {\n console.log(data.name); // 安全\n}\n\n// キーの操作:Tのキーのみを受け取る\nfunction getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {\n return obj[key];\n}\n\nconst user = { name: 'John', age: 30 };\ngetProperty(user, 'name'); // OK\n// getProperty(user, 'email'); // エラー:userにはemailプロパティがない\n
\n\n
4. 型の推論に頼る
\n\n
多くの場合、TypeScriptは文脈から型を自動推論できます。明示的に型を指定する必要がない場合は、推論に任せた方がコードがシンプルになります。
\n\n
// 明示的な型指定(不要)\nconst response = await fetchData<User>('/api/users/1');\n\n// 推論に任せる方がシンプル(推奨)\nconst data = await fetchData('/api/users/1'); // Userの型を推論\n
\n
\n\n
デバッグのコツ
\n\n
Genericsを使うときの一般的な問題と解決方法をまとめました。
\n\n

