ゴール:「クラスにも <T> が付くんだ、しかも“インスタンス全体の型のルール”になるんだ、を体で覚える
関数のジェネリクスはだいぶ見慣れてきたと思います。
クラスのジェネリクスは、その 「型パラメータを“インスタンス全体”に効かせる版」 です。
一度イメージをつかむと、
「このクラスは“何を扱うクラスなのか”を型で宣言できる」
ようになり、コレクション・リポジトリ・キャッシュ・キューなど、
“何かを入れて管理する系”のクラス設計が一気に気持ちよくなります。
まずは一番シンプルな「箱クラス」から
ジェネリクスなしのクラスだとどうなるか
まず、ジェネリクスを使わないクラスを見てみます。
class BoxAny {
private value: any;
constructor(value: any) {
this.value = value;
}
getValue(): any {
return this.value;
}
}
const b1 = new BoxAny(123);
const v1 = b1.getValue(); // any
TypeScriptany を使っているので、v1 の型は any になり、何でもできてしまいます。
v1.toUpperCase(); // コンパイルは通る(実行時に落ちるかもしれない)
TypeScriptこれは、セキュリティ的に言えば 「型チェックなしで何でも通す API」 みたいな状態です。
同じクラスをジェネリクスで書き直す
これをジェネリクスで書き直すと、こうなります。
class Box<T> {
private value: T;
constructor(value: T) {
this.value = value;
}
getValue(): T {
return this.value;
}
}
const bNumber = new Box<number>(123);
const n = bNumber.getValue(); // number
const bString = new Box("hello"); // 型推論で T = string
const s = bString.getValue(); // string
TypeScriptここでのポイントは 2つです。
ひとつ目は、クラス名の後ろに <T> が付いていること。
これで「このクラスは T という型パラメータを持つクラスです」と宣言しています。
ふたつ目は、フィールド・コンストラクタ・メソッドの型に T が使われていること。
これにより、「このインスタンスは“ある 1 種類の型 T の値だけを扱う箱”」になります。
any 版と違って、Box<number> からは必ず number が返り、Box<string> からは必ず string が返ります。
「インスタンスを作るときに“この箱は何用か”を決めて、その後はずっと守られる」
これがクラスのジェネリクスの基本イメージです。
コレクション系クラスでジェネリクスが本領発揮する
シンプルなスタック(LIFO)のジェネリッククラス
「配列ラッパー」的なクラスを考えてみます。
class Stack<T> {
private items: T[] = [];
push(item: T): void {
this.items.push(item);
}
pop(): T | undefined {
return this.items.pop();
}
size(): number {
return this.items.length;
}
}
const numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);
const n1 = numberStack.pop(); // number | undefined
const stringStack = new Stack<string>();
stringStack.push("a");
stringStack.push("b");
const s1 = stringStack.pop(); // string | undefined
TypeScriptここでのポイントは、
クラス定義側では「T が何か」を知らない
インスタンス生成時に「この Stack は number 用」「この Stack は string 用」と決まる
という構造です。
Stack<any> にしてしまうと、
「何でも入るし、何でも出てくる」危険なコンテナになりますが、Stack<number> や Stack<User> にすることで、
「このスタックには“この型だけ”が入る」という制約が型レベルでかかります。
セキュリティ視点での意味
例えば、認証済みユーザーだけを積むスタックを考えます。
type AuthenticatedUser = { id: number; name: string; token: string };
const authStack = new Stack<AuthenticatedUser>();
authStack.push({ id: 1, name: "Taro", token: "..." });
// authStack.push({ id: 2, name: "Hanako" }); // token がないのでコンパイルエラー
TypeScriptStack<AuthenticatedUser> にしておけば、
「token を持たない“未認証ユーザー”」が紛れ込むことをコンパイル時に防げます。
これは、「安全なコンテナに、危険なデータが混ざらないようにする」という意味で、
セキュリティ的にも非常に健全な設計です。
クラスのジェネリクスとメソッドのジェネリクスの違い
クラス全体の T と、メソッド固有の U
クラス自体がジェネリクスを持ちつつ、
メソッドがさらに別のジェネリクスを持つこともできます。
class Repository<T> {
private items: T[] = [];
add(item: T): void {
this.items.push(item);
}
findBy<K extends keyof T>(key: K, value: T[K]): T | undefined {
return this.items.find((item) => item[key] === value);
}
}
TypeScriptここでは、
クラスの型パラメータ:T(このリポジトリが扱うエンティティの型)
メソッド findBy の型パラメータ:K(T のキーのどれか)
という二段構えになっています。
使うとこうなります。
type User = { id: number; name: string; email: string };
const userRepo = new Repository<User>();
userRepo.add({ id: 1, name: "Taro", email: "taro@example.com" });
const u1 = userRepo.findBy("id", 1); // OK
const u2 = userRepo.findBy("email", "x@y.z"); // OK
// userRepo.findBy("age", 20); // エラー:age は User に存在しない
TypeScriptここでの重要ポイントは、
クラスのジェネリクス T が「このインスタンスが扱うデータの型」
メソッドのジェネリクス K が「その T に対する“操作のパラメータ”」
という役割を持っていることです。
「クラスのジェネリクスは“インスタンスの性質”、メソッドのジェネリクスは“操作ごとの可変部分”」
と覚えておくと整理しやすくなります。
制約付きジェネリクスクラス:扱える型に条件をつける
「id を持つものだけ扱うリポジトリ」
クラスのジェネリクスにも extends で制約をつけられます。
interface HasId {
id: number;
}
class IdRepository<T extends HasId> {
private items: T[] = [];
add(item: T): void {
this.items.push(item);
}
findById(id: number): T | undefined {
return this.items.find((item) => item.id === id);
}
}
TypeScript使うときはこうです。
type User = { id: number; name: string };
type Product = { id: number; title: string; price: number };
const userRepo = new IdRepository<User>(); // OK
const productRepo = new IdRepository<Product>(); // OK
// type NoId = { name: string };
// const badRepo = new IdRepository<NoId>(); // エラー:id がない
TypeScriptここでのポイントは、
「このクラスは“id を持つ型だけ”を扱う」
という制約を、型レベルで表現していることです。
セキュリティ的には、
「ID をキーにしたアクセス制御や監査ログを取りたいので、
ID を持たないものはそもそもこのリポジトリに入れさせない」
という設計になります。
クラスのジェネリクスと継承
ジェネリッククラスを継承する
ジェネリッククラスを継承することもできます。
class BaseStore<T> {
protected items: T[] = [];
add(item: T): void {
this.items.push(item);
}
getAll(): T[] {
return this.items;
}
}
class UserStore extends BaseStore<User> {
findByName(name: string): User | undefined {
return this.items.find((u) => u.name === name);
}
}
TypeScriptここでは、
BaseStore<T> が「汎用的なストア」UserStore が「User 専用ストア」として T = User を固定
という構造になっています。
UserStore の中では、this.items の型は User[] になります。
const store = new UserStore();
store.add({ id: 1, name: "Taro" });
const all = store.getAll(); // User[]
TypeScript「親クラスはジェネリック、子クラスで具体的な型を決める」
というパターンは、実務でもかなりよく使います。
ありがちなつまずきポイントと考え方
「クラスに <T> を付けるのを忘れて、全部 any になる」
よくあるのがこれです。
class BoxBad {
private value;
constructor(value) {
this.value = value;
}
getValue() {
return this.value;
}
}
TypeScript型注釈がないので、value も getValue も any になってしまいます。
「このクラスは“何を扱うクラスなのか”を型で表現したい」と思ったら、
まず クラス名の後ろに <T> を付ける癖をつけてください。
class BoxGood<T> {
private value: T;
constructor(value: T) { this.value = value; }
getValue(): T { return this.value; }
}
TypeScript「インスタンス生成時に <T> を付けるか、推論に任せるか」
const b1 = new Box<number>(123); // 明示
const b2 = new Box(123); // 推論(T = number)
TypeScriptどちらも正しいですが、
基本は 推論に任せて OK です。
ただし、T が複雑だったり、
「ここはあえて T を広く/狭くしたい」という意図があるときは、
明示的に <T> を書くとコードの意図が伝わりやすくなります。
まとめ:クラスのジェネリクスを自分の言葉で説明すると
最後に、あなた自身の言葉でこう整理してみてください。
クラスのジェネリクスは、
「クラス名の後ろに <T> を付けて、
その T をフィールド・コンストラクタ・メソッドの型に使うことで、
“このインスタンスは何を扱うクラスなのか”を型で固定する仕組み」。
Box<T> や Stack<T> のように、
- インスタンス生成時に「このクラスは number 用」「このクラスは User 用」と決める
- その後は、その型だけが出入りすることが型で保証される
という状態を作れる。
まずは次の 3 つを、自分の手で書いてみてください。
class Box<T> { /* value を 1 つ持つクラス */ }
class Stack<T> { /* push/pop を持つクラス */ }
class IdRepository<T extends { id: number }> { /* id で検索できるクラス */ }
TypeScriptそして、number・string・自作の型を T に入れてインスタンスを作り、
メソッドの戻り値の型がどう変わるかをエディタで眺めてみてください。
そこで、
「クラスのジェネリクスは、“インスタンス全体の型のルール”を決めるものなんだ」
と感じられたら、
クラスのジェネリクスの基礎はもうしっかり掴めています。
