TypeScript | 関数・クラス・ジェネリクス:ジェネリクス基礎 - 制約付きジェネリクス関数

TypeScript TypeScript
スポンサーリンク

ゴール:「なんでも T」ではなく「こういう T だけOK」を自分でコントロールできるようになる

制約付きジェネリクス関数は一言でいうと、

「ジェネリクスの“自由さ”に、ちょっとだけ“条件”を足した関数」

です。

普通のジェネリック関数は「T はなんでもアリ」です。
制約付きジェネリクス関数は「T はなんでもアリだけど、“この条件を満たすものだけ”」という世界にします。

これができると、

  • 関数の中で「安全に使えるプロパティ・メソッド」が増える
  • 間違った型を渡したときに、コンパイルエラーで止められる

という、かなり気持ちいい状態を作れます。

まずは「制約なしジェネリクス関数」との違いを押さえる

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

例えば、こんなジェネリック関数があります。

function logValue<T>(value: T): void {
  console.log(value);
}
TypeScript

この T には、どんな型でも入ります。

logValue(123);
logValue("hello");
logValue({ name: "Taro" });
TypeScript

ただし、関数の中では value の型は T なので、
value.lengthvalue.id のようなプロパティアクセスはできません。

function logLength<T>(value: T): void {
  // コンパイルエラー
  // console.log(value.length);
}
TypeScript

T が string かもしれないし、number かもしれないし、オブジェクトかもしれない。
コンパイラからすると「length を持つとは限らない」ので、止めてくれます。

ここで「T に条件をつけたい」という欲求が生まれます。

制約付きジェネリクス関数の基本形

「length を持つものだけ受け取る」関数

さっきの logLength を、制約付きジェネリクス関数に書き直してみます。

function logLength<T extends { length: number }>(value: T): void {
  console.log(value.length);
}
TypeScript

ここでのポイントは T extends { length: number } です。

これは、

「T はなんでもいいけど、少なくとも { length: number } を満たす型に限る」

という宣言です。

その結果、呼び出し側はこうなります。

logLength("hello");        // OK(string は length を持つ)
logLength([1, 2, 3]);      // OK(配列も length を持つ)
logLength({ length: 10 }); // OK
// logLength(123);         // エラー:number は length を持たない
TypeScript

そして関数の中では、value.length を安心して使えます。

「T に制約をつけることで、
中では“できること”が増え、
外では“渡せる型”が制限される」

これが制約付きジェネリクス関数の本質です。

よくある制約付きジェネリクス関数のパターン

パターン1:オブジェクトに限定してキーを扱う

「オブジェクトと、そのキーを一緒に扱う」関数は、制約付きジェネリクスの代表例です。

function getProp<TObj, TKey extends keyof TObj>(
  obj: TObj,
  key: TKey
): TObj[TKey] {
  return obj[key];
}
TypeScript

ここでの関係はこうです。

  • TObj は「オブジェクト全体の型」
  • TKey は「TObj のキーのどれか(keyof TObj)」

TKey extends keyof TObj という制約で、

「キーは、そのオブジェクトに存在するものだけに限る」

と宣言しています。

使ってみると違いがよく分かります。

const user = { id: 1, name: "Taro" };

const id = getProp(user, "id");     // id: number
const name = getProp(user, "name"); // name: string
// getProp(user, "age");            // エラー:"age" は存在しない
TypeScript

制約があるおかげで、「存在しないキー」を指定するとコンパイルエラーになります。
これは、制約なしジェネリクスでは絶対にできないことです。

パターン2:オブジェクトだけ受け取りたい

「プリミティブ(number, string など)は弾いて、オブジェクトだけ受け取りたい」こともよくあります。

function logKeys<T extends object>(obj: T): void {
  console.log(Object.keys(obj));
}
TypeScript

これで、

logKeys({ id: 1, name: "Taro" }); // OK
// logKeys(123);                  // エラー
// logKeys("hello");              // エラー
TypeScript

というふうに、「オブジェクト専用」の関数になります。

T extends object という制約があるからこそ、
関数の中で Object.keys(obj) のような「オブジェクト前提の処理」を安心して書けます。

パターン3:コンストラクタ(クラス)に限定する

「new できるものだけ受け取りたい」関数も、制約付きジェネリクスの典型です。

function createInstance<C extends new (...args: any[]) => any>(
  Ctor: C,
  ...args: ConstructorParameters<C>
): InstanceType<C> {
  return new Ctor(...args);
}
TypeScript

ここでの制約 C extends new (...args: any[]) => any は、

「C は“new できる型”(コンストラクタ)でなければならない」

という意味です。

その結果、

class User {
  constructor(public name: string) {}
}

const u = createInstance(User, "Taro"); // OK
// createInstance(123, "Taro");         // エラー:123 はコンストラクタではない
TypeScript

のように、「コンストラクタ以外を渡すミス」をコンパイル時に防げます。

制約付きジェネリクス関数を設計するときの考え方

ステップ1:関数の中で「何をしたいか」を先に言語化する

いきなり extends を書こうとするのではなく、
まずは自分にこう問いかけてください。

「この関数の中で、T に対して何をしたい?」

例えば、

  • length を読みたい
  • id を読みたい
  • new したい
  • 特定のキーでアクセスしたい

などです。

ステップ2:「それを安全に書くために必要な最小条件」を制約にする

次に、

「それを安全に書くには、T にどんな性質が必要か?」

を考えます。

  • length を使いたい → { length: number } を満たしていればいい
  • id を使いたい → { id: number } を満たしていればいい
  • new したい → new (...args: any[]) => any を満たしていればいい
  • キーでアクセスしたい → TKey extends keyof TObj が必要

これをそのまま extends の右側に書きます。

function f<T extends { length: number }>(value: T) { ... }
function g<T extends { id: number }>(value: T) { ... }
function h<C extends new (...args: any[]) => any>(Ctor: C) { ... }
TypeScript

ここで大事なのは、

「制約は“やりたいことのために必要な最小限”にする」

ということです。

厳しすぎる制約をつけると、呼び出し側が使いづらくなります。
ゆるすぎると、中で安全に書けなくなります。

ありがちな失敗と、その直し方

なんとなく extends をつけているだけ

例えば、こういうコードは意味がありません。

function doSomething<T extends any>(value: T) {
  // ...
}
TypeScript

T extends any は、制約なしと同じです。

あるいは、

function doSomething<T extends {}>(value: T) {
  // ...
}
TypeScript

これも「ほぼ何でもアリ」です(null/undefined を除く程度)。

制約を書くときは、必ず自分に問いかけてください。

「この extends は、関数の中で何を保証してくれている?」

答えられない制約は、たぶん要りません。

制約をつけずに、中で危ないことをしてしまう

例えば、こういう関数。

function getLengthBad<T>(value: T): number {
  // コンパイルエラー
  // return value.length;
}
TypeScript

ここで「エラーうざいから any にしちゃえ」とやると、こうなります。

function getLengthAny<T>(value: T): number {
  return (value as any).length; // コンパイルは通るが危険
}
TypeScript

これだと、getLengthAny(123) も通ってしまい、
実行時に落ちる可能性があります。

正しいのは、制約をつけることです。

function getLengthGood<T extends { length: number }>(value: T): number {
  return value.length;
}
TypeScript

「any でごまかしたくなったら、“本当はどんな制約が必要か”を考えるサイン」

だと覚えておくと、設計が一段上がります。

まとめ:制約付きジェネリクス関数を自分の言葉で説明すると

最後に、あなた自身の言葉でこう整理してみてください。

制約付きジェネリクス関数は、

「T はなんでもいいわけじゃなくて、“この条件を満たす型だけ”に絞ったジェネリック関数」。

制約をつけることで、

  • 関数の中では「その条件に基づいて安全にプロパティやメソッドを使える」
  • 呼び出し側では「条件を満たさない型を渡すとコンパイルエラーになる」

という状態を作れる。

まずは次の 2 つを、自分の手で書いてみてください。

function getLength<T extends { length: number }>(value: T): number {
  return value.length;
}

function getProp<TObj, TKey extends keyof TObj>(
  obj: TObj,
  key: TKey
): TObj[TKey] {
  return obj[key];
}
TypeScript

そして、わざと制約を破る呼び出し(getLength(123)getProp(user, "age"))を書いてみて、
コンパイルエラーになる感覚を味わってみてください。

そこで、

「extends は“型に条件をかける”ためのものなんだ。
そのおかげで、中でも外でも安全に書けるんだ。」

と腑に落ちたら、
制約付きジェネリクス関数の基礎はもうしっかり身についています。

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