TypeScript | 関数・クラス・ジェネリクス:ジェネリクス基礎 - 型パラメータの制約(extends)

TypeScript TypeScript
スポンサーリンク

ゴール:「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));
}
TypeScript

T extends object にすることで、
numberstring などのプリミティブは弾かれます。

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) {
  // ...
}
TypeScript

T extends any は、実質「制約なし」と同じです。
意味がありません。

あるいは、

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

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

制約を書くときは、

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

を自分に問いかけてください。

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

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

例えば、こういう関数。

function getLengthBad<T>(value: T): number {
  // エラーになる
  return value.length;
}
TypeScript

T に制約がないので、
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 は“型に条件をかける”ためのものなんだな。
そのおかげで、中でも外でも安全に書けるんだな。」

と腑に落ちたら、
型パラメータの制約の基礎はもうしっかり身についています。

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