ゴール:「T はなんでもアリ」から「T は“こういう型だけ”に絞る」感覚をつかむ
ジェネリクスに制約(extends)をつける一番の目的は、
「T はなんでもいいわけじゃなくて、“この条件を満たす型だけ”にしたい」
というときに、型レベルでそれを表現することです。
これが分かると、
「関数の中で安全に使えるプロパティ・メソッド」と
「呼び出し側が渡していい型の範囲」
を、きれいにコントロールできるようになります。
まずは制約なしの T を見てみる
制約なしの T は「本当に何でもアリ」
例えば、こんなジェネリック関数があります。
function logValue<T>(value: T): void {
console.log(value);
}
TypeScriptこの T には、どんな型でも入ります。
logValue(123);
logValue("hello");
logValue({ name: "Taro" });
TypeScript関数の中では value の型は T なので、value.toUpperCase() のようなことは書けません(T が string とは限らないから)。
つまり、
「何でも受け取れるけど、そのぶん中でできることは少ない」
という状態です。
型パラメータの制約とは何か
「T は“このグループの中のどれか”に限定する」
ここで extends の出番です。
例えば、「length プロパティを持つものだけ受け取りたい」関数を考えます。
function getLength<T extends { length: number }>(value: T): number {
return value.length;
}
TypeScriptここでやっていることは、
「T はなんでもいいけど、少なくとも { length: number } を満たす型に限る」
という宣言です。
その結果、
呼び出し側は:
getLength("hello"); // OK(string は length を持つ)
getLength([1, 2, 3]); // OK(配列も length を持つ)
getLength({ length: 10 }); // OK
// getLength(123); // エラー:number は length を持たない
TypeScript関数の中では:
value.length; // 安心して使える(T は必ず length を持つから)
TypeScriptという、かなり気持ちいい状態になります。
よくある制約パターン1:オブジェクトに限定する
「オブジェクトだけ受け取りたい」場合
例えば、「オブジェクトのキー一覧をログに出す」関数。
function logKeys<T extends object>(obj: T): void {
console.log(Object.keys(obj));
}
TypeScriptT extends object にすることで、number や string などのプリミティブは弾かれます。
logKeys({ id: 1, name: "Taro" }); // OK
// logKeys(123); // エラー
// logKeys("hello"); // エラー
TypeScriptこれにより、
「この関数は“オブジェクトっぽいもの”専用だよ」
という意図が、型としてもコードとしてもハッキリします。
「特定の形のオブジェクト」に限定する
もう少し踏み込んで、
「必ず id: number を持つものだけ受け取りたい」とします。
function logId<T extends { id: number }>(value: T): void {
console.log(value.id);
}
TypeScript呼び出し側はこうなります。
logId({ id: 1, name: "Taro" }); // OK
logId({ id: 2 }); // OK
// logId({ name: "Taro" }); // エラー:id がない
TypeScript関数の中では value.id を安心して使えます。
ここでのポイントは、
「T に制約をつけることで、“中で安全に使えるプロパティ”を増やせる」
ということです。
よくある制約パターン2:keyof を使って「キー」に縛る
オブジェクトとそのキーを一緒に扱う
ジェネリクス+制約の代表的なパターンがこれです。
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制約がないと「存在しないキー」も通ってしまいますが、
制約をつけることで、
「オブジェクトに存在しないキーを指定したらコンパイルエラー」
という安全な世界になります。
よくある制約パターン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 できる型”(コンストラクタ)でなければならない」
という意味です。
その結果、createInstance(123, ...) のような呼び出しはコンパイルエラーになります。
制約をつけることで、
「このジェネリックは“コンストラクタ専用”だよ」
という意図を、型として表現できています。
制約をつけるときの考え方
ステップ1:「関数の中で何をしたいか」を先に考える
いきなり T extends ... を書こうとするのではなく、
まずは「この関数の中で、T に対して何をしたいか」を言語化してみてください。
例えば、
length を読みたい → value.length を使いたい
id を読みたい → value.id を使いたい
new したい → new Ctor(...) を使いたい
などです。
ステップ2:「それが安全に書ける最小限の条件」を制約にする
次に、
「それを安全に書くために、T にどんな性質が必要か?」
を考えます。
length を使いたい → { length: number } を満たしていればいい
id を使いたい → { id: number } を満たしていればいい
new したい → new (...args: any[]) => any を満たしていればいい
これをそのまま 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) {
// ...
}
TypeScriptT extends any は、実質「制約なし」と同じです。
意味がありません。
あるいは、
function doSomething<T extends {}>(value: T) {
// ...
}
TypeScriptこれも「ほぼ何でもアリ」です(null/undefined を除く程度)。
制約を書くときは、
「この extends は、関数の中で何を保証してくれているのか?」
を自分に問いかけてください。
答えられない制約は、たぶん要りません。
制約をつけ忘れて、中で危ないことをしてしまう
例えば、こういう関数。
function getLengthBad<T>(value: T): number {
// エラーになる
return value.length;
}
TypeScriptT に制約がないので、value.length はコンパイルエラーです。
これを無理やり any でごまかすと、
型安全性が失われます。
function getLengthAny<T>(value: T): number {
return (value as any).length; // コンパイルは通るが危険
}
TypeScript正しいのは、制約をつけることです。
function getLengthGood<T extends { length: number }>(value: T): number {
return value.length;
}
TypeScript「any でごまかしたくなったら、“本当はどんな制約が必要か”を考えるサイン」
だと思ってください。
まとめ:型パラメータの制約(extends)を自分の言葉で説明すると
最後に、あなた自身の言葉でこう整理してみてください。
型パラメータの制約(extends)は、
「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そして、わざと制約を破る呼び出しを書いてみて、
コンパイルエラーになる感覚を味わってみてください。
そこで、
「extends は“型に条件をかける”ためのものなんだな。
そのおかげで、中でも外でも安全に書けるんだな。」
と腑に落ちたら、
型パラメータの制約の基礎はもうしっかり身についています。
