実践的なTypeScript Genericsの例

1. 概要

Genericsを使用することで、再利用可能なコードを書きながら、型安全性を保つことができます。UserRepository、OrderRepository、ProductRepositoryを何度も繰り返して書く代わりに、共通で使えるRepository<T>を1つ書けば済みます。この記事では、実例を通じて学びます:generic関数、genericクラス、制約(constraints)、複数の型パラメータ、そしてユーティリティ型との組み合わせです。この記事を読み終わる頃には、TypeScriptプロジェクトのサービス層、リポジトリ層、APIレスポンス層にgenericsを自信を持って適用できるようになります。

2. Genericsとは何か、そしてなぜ必要か

Genericsを使うことで、型を「パラメータ化」できます。これは関数がパラメータを受け取るのと同じことを型レベルで行うということです。

// genericsを使わない場合:各型に対して書き直さなければならない
function firstString(arr: string[]): string {
  return arr[0];
}
function firstNumber(arr: number[]): number {
  return arr[0];
}

// genericsを使う場合:すべての型に対応する1つの関数
function first<T>(arr: T[]): T {
  return arr[0];
}

const name = first(["Alice", "Bob"]); // T = string、型推論が自動で行われる
const age = first([30, 25]);          // T = number

<T>は型パラメータです — 実行時に渡される実際の型を表す変数です。TypeScriptは引数に基づいてTを自動的に推論(infer)するため、手動で指定する必要はありません。

注記: T、U、K、Vは単なる命名規則であり、特別な意味はありません。大規模なコードベースでは、TItem、TResponseのようにより明確な名前を付ける方が読みやすくなります。

3. 実践的なgeneric関数:groupBy関数

データ処理でよく見られる例:オブジェクトをあるフィールドでグループ化する場合です。

interface Order {
  id: number;
  status: "pending" | "paid" | "cancelled";
  customerId: number;
}

function groupBy<T, K extends string | number>(
  items: T[],
  keyFn: (item: T) => K
): Record<K, T[]> {
  const result = {} as Record<K, T[]>;
  for (const item of items) {
    const key = keyFn(item);
    (result[key] ??= []).push(item);
  }
  return result;
}

const orders: Order[] = [
  { id: 1, status: "paid", customerId: 10 },
  { id: 2, status: "pending", customerId: 11 },
  { id: 3, status: "paid", customerId: 10 },
];

const byStatus = groupBy(orders, (o) => o.status);
  • Tは配列内の項目の型(Order)です。
  • K extends string | numberはキーがstringまたはnumberのみに限定されます — オブジェクトのキーは他の型では使用できないためです。
  • 結果のbyStatusはRecord<"pending" | "paid" | "cancelled", Order[]>型であり、TypeScriptは正確にどのキーが出現する可能性があるかを認識しています。
byStatus.paid       -> Order[] (2要素)
byStatus.pending    -> Order[] (1要素)
byStatus.cancelled  -> Order[] (厳密モードではundefinedの可能性あり)

4. extendsを使ったgeneric制約

制約はTが特定の構造を満たす必要があることを指定し、generic関数内でプロパティに安全にアクセスできるようにします。

interface HasId {
  id: number;
}

function findById<T extends HasId>(items: T[], id: number): T | undefined {
  return items.find((item) => item.id === id);
}

interface Customer extends HasId {
  name: string;
  email: string;
}

const customers: Customer[] = [
  { id: 1, name: "An", email: "an@example.com" },
  { id: 2, name: "Bình", email: "binh@example.com" },
];

const found = findById(customers, 1); // Customer | undefined

extends HasIdがない場合、item.idの行はエラーになります。TypeScriptはTがidプロパティを持つかどうかを知ることができないためです。

error TS2339: Property 'id' does not exist on type 'T'.

これはgenericsを学び始めた時に最も一般的なエラーです:制約を忘れてTの任意のプロパティにアクセスしようとします。

5. Generic クラス:Repository<T>の例

これは典型的なユースケースです — 複数のエンティティに対応するgenericリポジトリです。

interface Entity {
  id: number;
}

class Repository<T extends Entity> {
  private items: Map<number, T> = new Map();

  save(item: T): T {
    this.items.set(item.id, item);
    return item;
  }

  findById(id: number): T | undefined {
    return this.items.get(id);
  }

  findAll(): T[] {
    return Array.from(this.items.values());
  }

  deleteById(id: number): boolean {
    return this.items.delete(id);
  }
}

interface Product extends Entity {
  name: string;
  price: number;
}

const productRepo = new Repository<Product>();
productRepo.save({ id: 1, name: "キーボード", price: 500_000 });
productRepo.save({ id: 2, name: "マウス", price: 200_000 });

const all = productRepo.findAll(); // Product[]
  • Repository<T extends Entity>により、管理されるすべてのエンティティがidを持つことが保証されるため、Map<number, T>でidをキーとして使用するのは安全です。
  • new Repository<Product>()と宣言すると、すべてのメソッドの戻り値は正確にProduct型になります — IDEはname、priceの自動補完を提供します。

注記: プロジェクト全体でRepositoryを1つのエンティティにのみ使用する場合、genericsは過度な設計です。まず具体的なクラスを書き、共通のロジックが2か所以上で使用されている場合のみgenericにしてください。

6. 複数の型パラメータ:ApiResponse<TData, TError>の例

複数の型をパラメータ化する必要がある場合、複数の型パラメータを同時に使用します。

interface ApiResponse<TData, TError = string> {
  success: boolean;
  data?: TData;
  error?: TError;
}

function fetchSuccess<T>(data: T): ApiResponse<T> {
  return { success: true, data };
}

function fetchFailure<T, E = string>(error: E): ApiResponse<T, E> {
  return { success: false, error };
}

interface User {
  id: number;
  username: string;
}

const okResponse: ApiResponse<User> = fetchSuccess({ id: 1, username: "nonladev" });

const errResponse: ApiResponse<User, { code: number; message: string }> = fetchFailure({
  code: 404,
  message: "ユーザーが見つかりません",
});

インターフェース宣言行のTError = stringはデフォルト型パラメータです — 2番目の型を渡さない場合、TypeScriptは自動的にstringを使用します。これがApiResponse<User>が1つの型引数だけでも有効である理由です。

7. Genericsとユーティリティ型の組み合わせ

Partial、Pick、Omitのようなユーティリティ型は、実際にはTypeScriptで定義済みのgeneric型です。これらを自分のgenericと組み合わせることで、更新関数に非常に効果的です。

interface Customer {
  id: number;
  name: string;
  email: string;
  vip: boolean;
}

function updateEntity<T extends { id: number }>(
  original: T,
  patch: Partial<Omit<T, "id">>
): T {
  return { ...original, ...patch };
}

const customer: Customer = { id: 1, name: "An", email: "an@a.com", vip: false };

const updated = updateEntity(customer, { vip: true });
// updated: Customer { id: 1, name: "An", email: "an@a.com", vip: true }
  • Partial<Omit<T, "id">>は:idを除くTのすべてのフィールドを取得し、その後すべてのフィールドをオプショナルにすることを意味します。
  • その結果、updateEntityはidの編集を許可せず、呼び出し側は変更したいフィールドだけを渡す必要があります — REST APIのPATCHパターンと非常に似ています。

patchに{ id: 2 }を渡そうとすると、TypeScriptはすぐにエラーを報告します。なぜならidは許可された型からOmitによって削除されているためです:

error TS2345: Object literal may only specify known properties,
and 'id' does not exist in type 'Partial<Omit<Customer, "id">>'.

8. 条件付き型を使ったGeneric:inferが必要な場合

より高度なステップ:関数の戻り値の型を自動的に取得し、手動で重複を避けます。

type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;

async function fetchCustomer(id: number): Promise<Customer> {
  return { id, name: "An", email: "an@a.com", vip: false };
}

type FetchResult = UnwrapPromise<ReturnType<typeof fetchCustomer>>;
// FetchResult = Customer、Promise<Customer>ではなく
  • infer UはTypeScriptにPromise<...>内の型を「推測」させ、それをUに割り当てます。
  • fetchCustomerが何を返すかを事前に知る必要はありません — 型は自動的に推論され、fetchCustomerの署名が変わると、FetchResultは自動的に更新されます。

注記: inferを使った条件付き型は非常に強力ですが、コードが読みにくくなるリスクがあります。UnwrapPromiseのような型ユーティリティを何度も再利用する必要がある場合のみ使用してください。単一用途のロジックに乱用してはいけません。

9. Generic関数のテストを書く

Genericsはコンパイル時の型チェックに影響しますが、実行時の動作は通常通りテストする必要があります。Vitestをインストールしましょう:

<!-- package.json(dependenciesセクション、pom.xmlではなく) -->
npm install -D vitest typescript
// repository.test.ts
import { describe, it, expect } from "vitest";

interface Entity {
  id: number;
}

class Repository<T extends Entity> {
  private items: Map<number, T> = new Map();
  save(item: T): T {
    this.items.set(item.id, item);
    return item;
  }
  findById(id: number): T | undefined {
    return this.items.get(id);
  }
}

interface Product extends Entity {
  name: string;
}

describe("Repository<Product>", () => {
  it("idで正しいproductを保存して取得する", () => {
    const repo = new Repository<Product>();
    repo.save({ id: 1, name: "キーボード" });

    const found = repo.findById(1);

    expect(found).toBeDefined();
    expect(found?.name).toBe("キーボード");
  });

  it("idが存在しない場合はundefinedを返す", () => {
    const repo = new Repository<Product>();

    const found = repo.findById(999);

    expect(found).toBeUndefined();
  });
});

npx vitest runを実行すると、期待される結果は以下の通りです:

✓ Repository<Product> > idで正しいproductを保存して取得する
✓ Repository<Product> > idが存在しない場合はundefinedを返す

Test Files  1 passed (1)
     Tests  2 passed (2)

このテストは型をチェックしません(TypeScriptコンパイラはビル時にそれを行います)。代わりに、genericクラスの実行時ロジックをチェックしています — MapがどのようなTでも正しく動作することを確認します。

10. よくあるエラーとgenericsを使うべきでない場合

  • 過度なgeneric化(over-generic):Repository<T>を作成しますが、プロジェクト全体で1つのエンティティしかない場合。この場合、具体的なクラスCustomerRepositoryの方が読みやすく、保守しやすいです。

  • 制約を忘れる:function getProp<T>(obj: T, key: string)を書いてからobj[key]にアクセスすると、型anyまたはインデックスシグネチャエラーになります。代わりに<T, K extends keyof T>(obj: T, key: K): T[K]を使用してください。

  • genericの代わりにanyを使う:function identity(x: any): anyは型安全性を完全に失わせます — TypeScriptはもはや何のエラーも報告しません。常に<T>(x: T): Tを優先してください。

  • Generic型パラメータが多すぎる:関数に4~5個の型パラメータがある場合、コードは非常に読みにくくなります。型を1つの設定インターフェースにまとめることを検討してください。

// 読みにくい
function merge<A, B, C, D>(a: A, b: B, c: C, d: D) { /* ... */ }

// より読みやすい
interface MergeInput<TBase, TExtra> {
  base: TBase;
  extra: TExtra;
}
function merge<TBase, TExtra>(input: MergeInput<TBase, TExtra>) { /* ... */ }

注記: 型パラメータが関数シグネチャ全体でちょうど1回だけ使用される場合、おそらくgenerisは必要ありません — 具体的な型またはunknownで十分です。

11. 結論

Genericsを使うことで、型安全性を損なうことなく、再利用可能なコードを書くことができます:1つのRepository<T>、1つのApiResponse<TData, TError>で、何十ものクラス/インターフェースの重複を避けることができます。鍵となるのは3つのことをマスターすることです — 基本的な型パラメータ、extendsを使った制約、そして組み込みのユーティリティ型(Partial、Omit、Pick)との組み合わせです。最初からすべてをgenericにしないでください;コードベース内の少なくとも2つの異なる場所でロジックが繰り返されるのを見たときだけ、genericに抽出してください。

typescriptgenericsjavascripttype-safetyfrontend