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を導入する際は、以下の優先順位をお勧めします:
- APIレスポンス処理:最も効果が高く、導入も容易
- Repository/DAO層:データベース操作を抽象化してからGenericを導入
- ユーティリティ関数:配列処理やフィルタリング関数
- 複雑な型安全:フォームバリデーションなど、複雑な要件に対応
チーム内での統一
Genericsの使用パターンをドキュメント化し、チーム内で統一することが重要です。プロジェクト初期段階で以下を決めておくと良いでしょう:
- 型変数の命名規則(T, U, V、またはより説明的な名前か)
- Generic制約の統一的な書き方
- 複雑すぎる型は避け、シンプルさを重視
7. まとめ
TypeScript Genericsは、単なる型システムの機能ではなく、実務でのコード品質向上、保守性向上、重複排除に直結する重要なツールです。
実務での活用ポイント:
- APIレスポンス処理で使う:即座にコードの質が向上する
- Repository パターンで使う:複数のエンティティに対応でき、スケーラビリティが向上
- キャッシュなど汎用機構で使う:型安全性を保ちながら再利用性を確保
- 複雑すぎないレベルに抑える:チーム全体が理解できることが最優先
- ランタイムと型時間の違いを理解:型消失を考慮した設計
本記事で紹介したパターンは、実際のプロジェクトで今日から使える実装例ばかりです。最初は APIレスポンス処理から始め、段階的にGenericsの活用を広げていくことをお勧めします。型安全性と再利用性の向上により、バグが減り、保守がしやすいコードベースが実現します。

