【TypeScript】ジェネリクスの制約(extends)を使いこなす

JavaScript

こんにちは、かつコーチです。

前回はジェネリクスの基本を扱い、型を引数のように渡す考え方を紹介しました。

今回はその一歩先、ジェネリクスの制約(extendsを扱います。

「ジェネリクスは書けるようになったけど、T に対して自由にプロパティへアクセスできなくて困った」という人は多いはずです。

私も最初にジェネリクスを実務で使ったとき、まさにこの壁にぶつかりました。

ジェネリクス制約とは?

制約なしジェネリクスの限界

ジェネリクスの型パラメータ T は、何も制約をつけなければ「どんな型でも受け取れる」状態になります。

裏を返せば、TypeScriptは T の中身について一切の情報を持っていないということです。

function getLength<T>(item: T): number {
  return item.length; // エラー:Property 'length' does not exist on type 'T'.
}

item が文字列や配列であれば .length は存在しますが、T は数値やオブジェクトかもしれません。

TypeScriptは「Tlength があるとは限らない」と判断するため、コンパイルエラーになります。

extendsで制約をかける基本構文

ここで使うのが extends です。

T extends 何らかの型 と書くことで、「T は少なくともこの型の形を持っている」とTypeScriptに約束できます。

interface HasLength {
  length: number;
}

function getLength<T extends HasLength>(item: T): number {
  return item.length; // OK:T は必ず length を持つと保証されている
}

console.log(getLength("かつコーチ")); // 5
console.log(getLength([1, 2, 3]));    // 3

HasLength インターフェースに length: number を定義し、T extends HasLength とすることで、item.length へ安全にアクセスできるようになりました。

文字列や配列のように length プロパティを持つ型なら、何でも渡せる汎用的な関数が完成します。

実践的な制約の書き方

プロパティの存在を保証する制約

実務でよく使うのは、「特定のプロパティを持つオブジェクト」に制約する書き方です。

interface HasId {
  id: number;
}

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

type User = { id: number; name: string };
const users: User[] = [
  { id: 1, name: "かつコーチ" },
  { id: 2, name: "ゲスト" },
];

const found = findById(users, 1);
console.log(found?.name); // かつコーチ

T extends HasId としておくことで、User に限らず id: number を持つあらゆる型の配列に対して、同じ findById 関数を使い回せます。

戻り値も T のまま返るので、呼び出し側では User 型として扱われ、name プロパティにも問題なくアクセスできます。

keyofと組み合わせて安全にプロパティアクセスする

制約は keyof と組み合わせると、さらに強力になります。

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { id: 1, name: "かつコーチ", age: 34 };

const name = getProperty(user, "name"); // 型は string
const age = getProperty(user, "age");   // 型は number

K extends keyof T と書くことで、「keyT に実在するプロパティ名でなければならない」という制約になります。

存在しないプロパティ名を渡すと、その場でコンパイルエラーとして検出できます。

getProperty(user, "email"); // エラー:Argument of type '"email"' is not assignable to parameter of type '"id" | "name" | "age"'.

タイプミスや存在しないキーを実行前に発見できるのは、ジェネリクス制約の大きなメリットです。

つまずきやすいポイント・エラー対処

制約なしでプロパティアクセスしようとして詰まった話

私が実際につまずいたのは、汎用的な「配列をIDでグルーピングする関数」を作ろうとしたときでした。

❌ Before:制約なしで書いてエラーになった最初のコード

function groupById<T>(items: T[]): Record<number, T> {
  const result: Record<number, T> = {};
  for (const item of items) {
    result[item.id] = item; // エラー:Property 'id' does not exist on type 'T'.
    // TS2339: Property 'id' does not exist on type 'T'.
  }
  return result;
}

「どんな配列でもグルーピングできる関数にしたい」という気持ちが先行し、T に何の制約もつけずに item.id を使ってしまったのが原因です。

エラーメッセージだけを見ても最初は「なぜ id が使えないのか」がピンと来ず、しばらく型定義を眺めて考え込みました。

✅ After:extendsで「idを持つ型」に制約してから使う

interface HasId {
  id: number;
}

function groupById<T extends HasId>(items: T[]): Record<number, T> {
  const result: Record<number, T> = {};
  for (const item of items) {
    result[item.id] = item; // OK:T は必ず id を持つと保証されている
  }
  return result;
}

const products = [
  { id: 101, name: "商品A" },
  { id: 102, name: "商品B" },
];

const grouped = groupById(products);
console.log(grouped[101].name); // 商品A

「汎用的にしたい=制約をつけない」ではなく、「必要最低限の形だけを制約として明示する」のが正解でした。

HasId のような最小限のインターフェースを制約に使うことで、汎用性を保ちながら型安全性も両立できます。

応用・一歩先の使い方

複数の型パラメータに制約をかける

型パラメータは複数持つこともでき、それぞれに個別の制約をかけられます。

interface Named {
  name: string;
}

interface Aged {
  age: number;
}

function introduce<T extends Named, U extends Aged>(person: T, pet: U): string {
  return `${person.name}さんが飼っているペットは${pet.age}歳です`;
}

introduce({ name: "かつコーチ" }, { age: 3 });

TU それぞれに別の制約をかけることで、複数の引数間で異なる形を保証しながら組み合わせられます。

デフォルト型引数と組み合わせる

制約にはデフォルト値も設定できます。

interface ApiResponse<T extends object = Record<string, unknown>> {
  status: number;
  data: T;
}

const res1: ApiResponse = { status: 200, data: { message: "ok" } };
const res2: ApiResponse<{ userId: number }> = { status: 200, data: { userId: 1 } };

T extends object = Record<string, unknown> とすることで、型引数を省略した場合のフォールバック先を用意しつつ、object という最低限の制約も同時に維持できます。

API レスポンスの共通型を定義するときなど、汎用性と安全性を両立したい場面でよく使うパターンです。

まとめ

この記事のポイント

  • 制約なしのジェネリクス T には、プロパティへ自由にアクセスできない
  • T extends インターフェース で、「最低限この形は持っている」という約束をつけられる
  • K extends keyof T を使うと、存在しないプロパティ名をコンパイル時に検出できる
  • 汎用性を保ちたいときほど、必要最小限のインターフェースを制約として使うのがコツ
  • 型パラメータは複数持てて、それぞれに個別の制約やデフォルト値を設定できる

次に読むべき記事

制約を使いこなせるようになったら、次は既存の型を加工する「ユーティリティ型」を見ていきましょう。

→ 次の記事:ユーティリティ型入門:Partial・Pick・Omitの使い方

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