ジェネリクスの制約
この章では、ジェネリクスに制約を設ける方法と、ジェネリッククラス・インターフェースについて学びます。
この章で学ぶこと
- extendsによる型制約
- 複数の型パラメータ
- ジェネリッククラス
- ジェネリックインターフェース
- デフォルト型パラメータ
制約を使うことで、ジェネリクスの柔軟性を保ちながら、必要なプロパティやメソッドを持つ型のみを受け入れることができます。
extendsによる型制約
型パラメータに制約を設けることで、特定のプロパティやメソッドを持つ型のみを受け入れることができます。
制約なしの問題
// 制約なし(すべての型を受け入れる)
function logLength<T>(value: T): void {
// Error: Tにlengthプロパティがあるとは限らない
// console.log(value.length);
}
// Tはどんな型でもよいので、lengthプロパティの存在は保証されない
extendsで制約を追加
// lengthプロパティを持つ型を定義
interface HasLength {
length: number;
}
// T extends HasLength で制約を追加
function logLength<T extends HasLength>(value: T): void {
// OK: Tは必ずlengthを持つことが保証される
console.log(value.length);
}
// 使用例
logLength("Hello"); // 5(stringはlengthを持つ)
logLength([1, 2, 3]); // 3(配列はlengthを持つ)
logLength({ length: 10 }); // 10(lengthプロパティを持つオブジェクト)
// Error: numberはlengthを持たない
// logLength(42);
オブジェクト型への制約
// オブジェクト型に制約
function getProperty<T extends object, K extends keyof T>(
obj: T,
key: K
): T[K] {
return obj[key];
}
const user = {
name: "山田太郎",
age: 30,
email: "yamada@example.com"
};
const name = getProperty(user, "name"); // string型
const age = getProperty(user, "age"); // number型
// Error: 存在しないキー
// getProperty(user, "invalid");
// Error: プリミティブ型は不可
// getProperty(123, "toString");
複数の制約
Intersection型(&)で複数の制約を組み合わせられます。
// 複数のインターフェースを満たす制約
interface Printable {
print(): void;
}
interface Loggable {
log(): void;
}
// PrintableとLoggableの両方を満たす必要がある
function execute<T extends Printable & Loggable>(obj: T): void {
obj.print();
obj.log();
}
class Document implements Printable, Loggable {
print(): void {
console.log("Printing document...");
}
log(): void {
console.log("Logging document...");
}
}
const doc = new Document();
execute(doc); // OK
// Error: Loggableを実装していない
// execute({ print: () => console.log("print") });
複数の型パラメータ
基本的な使い方
// 2つの型パラメータ
function pair<T, U>(first: T, second: U): [T, U] {
return [first, second];
}
const p1 = pair<number, string>(1, "one"); // [number, string]
const p2 = pair<string, boolean>("flag", true); // [string, boolean]
const p3 = pair(42, "answer"); // 型推論で [number, string]
型パラメータ間の制約
// KはTのキーに制約される
function getKey<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const product = {
id: 1,
name: "ノートPC",
price: 100000
};
const productName = getKey(product, "name"); // string
const productPrice = getKey(product, "price"); // number
// Error: "invalid"はproductのキーではない
// getKey(product, "invalid");
実践例:オブジェクトの変換
// キーを保持しながら値を変換
function mapObject<K extends string, V, R>(
obj: Record<K, V>,
mapper: (value: V) => R
): Record<K, R> {
const result = {} as Record<K, R>;
for (const key in obj) {
result[key] = mapper(obj[key]);
}
return result;
}
const numbers = { a: 1, b: 2, c: 3 };
const doubled = mapObject(numbers, (n) => n * 2);
// { a: 2, b: 4, c: 6 }
const strings = mapObject(numbers, (n) => `Value: ${n}`);
// { a: "Value: 1", b: "Value: 2", c: "Value: 3" }
ジェネリッククラス
クラス全体で型パラメータを使用できます。
基本的なジェネリッククラス
// ジェネリッククラスの定義
class Box<T> {
private value: T;
constructor(value: T) {
this.value = value;
}
getValue(): T {
return this.value;
}
setValue(value: T): void {
this.value = value;
}
}
// 使用例
const numberBox = new Box<number>(42);
console.log(numberBox.getValue()); // 42
numberBox.setValue(100);
// numberBox.setValue("string"); // Error: stringは代入できない
const stringBox = new Box<string>("Hello");
console.log(stringBox.getValue()); // "Hello"
stringBox.setValue("World");
実用的な例:データストア
// 簡易データストアクラス
class DataStore<T extends { id: number }> {
private data: T[] = [];
add(item: T): void {
this.data.push(item);
}
findById(id: number): T | undefined {
return this.data.find(item => item.id === id);
}
getAll(): T[] {
return [...this.data]; // コピーを返す
}
remove(id: number): boolean {
const index = this.data.findIndex(item => item.id === id);
if (index !== -1) {
this.data.splice(index, 1);
return true;
}
return false;
}
update(id: number, updates: Partial<T>): boolean {
const item = this.findById(id);
if (item) {
Object.assign(item, updates);
return true;
}
return false;
}
}
// 注: Partial<T> は「すべてのプロパティをオプショナルにする」ユーティリティ型です。
// 次章(15章)で Partial / Required / Pick / Omit などと合わせて詳しく扱います。
// 使用例
interface User {
id: number;
name: string;
email: string;
}
const userStore = new DataStore<User>();
userStore.add({ id: 1, name: "山田太郎", email: "yamada@example.com" });
userStore.add({ id: 2, name: "田中花子", email: "tanaka@example.com" });
console.log(userStore.findById(1));
// { id: 1, name: "山田太郎", email: "yamada@example.com" }
userStore.update(1, { name: "山田次郎" });
console.log(userStore.getAll());
// 別のエンティティでも使用可能
interface Product {
id: number;
name: string;
price: number;
}
const productStore = new DataStore<Product>();
productStore.add({ id: 1, name: "ノートPC", price: 100000 });
ジェネリッククラスの継承
// ベースクラス
class Container<T> {
constructor(protected value: T) {}
getValue(): T {
return this.value;
}
}
// 型を固定して継承
class NumberContainer extends Container<number> {
double(): number {
return this.value * 2;
}
}
// ジェネリックのまま継承
class PrintableContainer<T> extends Container<T> {
print(): void {
console.log(this.value);
}
}
const numContainer = new NumberContainer(10);
console.log(numContainer.double()); // 20
const strContainer = new PrintableContainer<string>("Hello");
strContainer.print(); // "Hello"
ジェネリックインターフェース
基本的なジェネリックインターフェース
// ジェネリックインターフェース
interface Result<T> {
success: boolean;
data?: T;
error?: string;
}
// 使用例
const successResult: Result<number> = {
success: true,
data: 42
};
const errorResult: Result<string> = {
success: false,
error: "データが見つかりません"
};
// 関数の戻り値として使用
interface User {
id: number;
name: string;
email: string;
}
function fetchUser(id: number): Result<User> {
if (id > 0) {
return {
success: true,
data: { id, name: "山田太郎", email: "yamada@example.com" }
};
}
return {
success: false,
error: "無効なID"
};
}
非同期処理の結果型
// 非同期結果型
interface AsyncResult<T, E = Error> {
data?: T;
error?: E;
loading: boolean;
}
// 使用例
const loadingState: AsyncResult<User> = {
loading: true
};
const successState: AsyncResult<User> = {
data: { id: 1, name: "山田太郎", email: "yamada@example.com" },
loading: false
};
const errorState: AsyncResult<User, string> = {
error: "ネットワークエラー",
loading: false
};
関数型のジェネリックインターフェース
// 関数型のジェネリックインターフェース
interface Transformer<T, R> {
(value: T): R;
}
// 使用例
const numberToString: Transformer<number, string> = (n) => n.toString();
const stringToNumber: Transformer<string, number> = (s) => parseInt(s);
console.log(numberToString(42)); // "42"
console.log(stringToNumber("123")); // 123
// 配列の変換に使用
function mapArray<T, R>(arr: T[], transformer: Transformer<T, R>): R[] {
return arr.map(transformer);
}
const numbers = [1, 2, 3];
const strings = mapArray(numbers, numberToString); // ["1", "2", "3"]
デフォルト型パラメータ
型パラメータにデフォルト値を設定できます。
基本的なデフォルト型パラメータ
// デフォルト型パラメータ
interface Response<T = unknown> {
status: number;
data: T;
}
// 型パラメータを指定しない場合、unknownになる
const response1: Response = {
status: 200,
data: "何でもOK" // unknown型
};
// 型パラメータを指定
const response2: Response<User> = {
status: 200,
data: { id: 1, name: "山田太郎", email: "yamada@example.com" }
};
制約とデフォルトの組み合わせ
// 制約とデフォルトの組み合わせ
interface Config<T extends object = { [key: string]: unknown }> {
settings: T;
version: string;
}
// デフォルト型を使用
const config1: Config = {
settings: { theme: "dark", language: "ja" },
version: "1.0.0"
};
// 具体的な型を指定
interface AppSettings {
theme: "light" | "dark";
language: string;
notifications: boolean;
}
const config2: Config<AppSettings> = {
settings: {
theme: "dark",
language: "ja",
notifications: true
},
version: "1.0.0"
};
複数のデフォルト型パラメータ
// 複数のデフォルト型パラメータ
interface ApiResponse<T = unknown, E = Error> {
data?: T;
error?: E;
statusCode: number;
}
// すべてデフォルト
const res1: ApiResponse = {
data: { message: "OK" },
statusCode: 200
};
// 1つ目のみ指定
const res2: ApiResponse<User> = {
data: { id: 1, name: "太郎", email: "test@example.com" },
statusCode: 200
};
// 両方指定
const res3: ApiResponse<User, string> = {
error: "Not Found",
statusCode: 404
};
実践例:型安全なイベントエミッター
// イベント型の定義
interface EventMap {
click: { x: number; y: number };
change: { value: string };
submit: { data: Record<string, unknown> };
}
// 型安全なイベントエミッター
class EventEmitter<T extends Record<string, unknown>> {
private handlers: Map<keyof T, ((data: unknown) => void)[]> = new Map();
on<K extends keyof T>(event: K, handler: (data: T[K]) => void): void {
if (!this.handlers.has(event)) {
this.handlers.set(event, []);
}
this.handlers.get(event)!.push(handler as (data: unknown) => void);
}
emit<K extends keyof T>(event: K, data: T[K]): void {
const eventHandlers = this.handlers.get(event);
if (eventHandlers) {
eventHandlers.forEach(handler => handler(data));
}
}
off<K extends keyof T>(event: K, handler: (data: T[K]) => void): void {
const eventHandlers = this.handlers.get(event);
if (eventHandlers) {
const index = eventHandlers.indexOf(handler as (data: unknown) => void);
if (index !== -1) {
eventHandlers.splice(index, 1);
}
}
}
}
// 使用例
const emitter = new EventEmitter<EventMap>();
// 型安全なイベントハンドラ
emitter.on("click", (data) => {
console.log(`Clicked at (${data.x}, ${data.y})`);
});
emitter.on("change", (data) => {
console.log(`Value changed to: ${data.value}`);
});
// 型安全なemit
emitter.emit("click", { x: 100, y: 200 });
emitter.emit("change", { value: "new value" });
// Error: 型が一致しない
// emitter.emit("click", { value: "string" });
試してみよう: データストアクラスを作ろう ★★★
以下の要件を満たすジェネリックなデータストアクラスを実装してください。
要件:
T extends { id: number }の制約を持つadd(item: T): アイテムを追加findById(id: number): IDでアイテムを検索update(id: number, updates: Partial<T>): アイテムを部分更新delete(id: number): アイテムを削除filter(predicate: (item: T) => boolean): 条件に合うアイテムを取得
// 以下のクラスを実装してください
// class Store<T extends { id: number }> {
// ...
// }
ヒント
- 内部データは
private items: T[] = []で保持 findByIdはfindメソッドを使用updateはObject.assignで部分更新deleteはfindIndexとspliceを使用filterは配列のfilterメソッドを使用
回答と解説
class Store<T extends { id: number }> {
private items: T[] = [];
// アイテムを追加
add(item: T): void {
// 同じIDがないかチェック
if (this.findById(item.id)) {
throw new Error(`ID ${item.id} already exists`);
}
this.items.push(item);
}
// IDでアイテムを検索
findById(id: number): T | undefined {
return this.items.find(item => item.id === id);
}
// アイテムを部分更新
update(id: number, updates: Partial<T>): boolean {
const item = this.findById(id);
if (item) {
// idは更新不可にする
const { id: _, ...safeUpdates } = updates;
Object.assign(item, safeUpdates);
return true;
}
return false;
}
// アイテムを削除
delete(id: number): boolean {
const index = this.items.findIndex(item => item.id === id);
if (index !== -1) {
this.items.splice(index, 1);
return true;
}
return false;
}
// 条件に合うアイテムを取得
filter(predicate: (item: T) => boolean): T[] {
return this.items.filter(predicate);
}
// すべてのアイテムを取得
getAll(): T[] {
return [...this.items]; // コピーを返す
}
// アイテム数を取得
count(): number {
return this.items.length;
}
}
// テスト
interface Task {
id: number;
title: string;
completed: boolean;
priority: "low" | "medium" | "high";
}
const taskStore = new Store<Task>();
// 追加
taskStore.add({ id: 1, title: "TypeScript学習", completed: false, priority: "high" });
taskStore.add({ id: 2, title: "レポート作成", completed: false, priority: "medium" });
taskStore.add({ id: 3, title: "メール返信", completed: true, priority: "low" });
// 検索
console.log(taskStore.findById(1));
// { id: 1, title: "TypeScript学習", completed: false, priority: "high" }
// 更新
taskStore.update(1, { completed: true });
console.log(taskStore.findById(1));
// { id: 1, title: "TypeScript学習", completed: true, priority: "high" }
// フィルター
const incompleteTasks = taskStore.filter(task => !task.completed);
console.log(incompleteTasks);
// [{ id: 2, title: "レポート作成", ... }]
const highPriorityTasks = taskStore.filter(task => task.priority === "high");
console.log(highPriorityTasks);
// [{ id: 1, title: "TypeScript学習", ... }]
// 削除
taskStore.delete(3);
console.log(taskStore.count()); // 2
解説:
T extends { id: number }により、必ずidプロパティを持つ型のみ受け入れますPartial<T>を使うことで、一部のプロパティのみの更新が可能になりますgetAll()でコピーを返すことで、外部からの直接変更を防ぎます- ジェネリクスにより、どんなエンティティ型(User, Product, Taskなど)でも型安全に使用できます
まとめ
この章で学んだこと:
- extendsによる制約: 型パラメータに条件を設ける
- 複数の型パラメータ:
<T, U>で複数の型を扱う - 型パラメータ間の制約:
K extends keyof Tでキーを制約 - ジェネリッククラス: クラス全体で型パラメータを使用
- ジェネリックインターフェース: インターフェースで型パラメータを使用
- デフォルト型パラメータ:
<T = unknown>でデフォルト値を設定
次の章では、TypeScriptの組み込みユーティリティ型について学びます。
初学者がつまずきやすいポイント
よくある間違い
❌ 制約を過度に厳しくする
// ❌ 必要以上に厳しい制約
interface FullUser {
id: number;
name: string;
email: string;
age: number;
}
function getUserId<T extends FullUser>(user: T): number {
return user.id;
}
// 制約が厳しすぎて、emailやageがないオブジェクトは渡せない
const simpleUser = { id: 1, name: "太郎" };
// getUserId(simpleUser); // Error
// ✅ 必要最小限の制約
function getUserIdSafe<T extends { id: number }>(user: T): number {
return user.id;
}
getUserIdSafe(simpleUser); // OK
原因: 必要以上に厳しい制約は、再利用性を損ないます。 解決策: 関数内で実際に使用するプロパティだけを制約に含めましょう。
❌ ジェネリッククラスで静的メンバーに型パラメータを使う
// ❌ 静的メンバーに型パラメータは使えない
class Container<T> {
static defaultValue: T; // Error: 静的メンバーは型パラメータを参照できない
constructor(public value: T) {}
}
// ✅ 静的メンバーには具体的な型を使う
class ContainerFixed<T> {
static defaultValue: unknown = null; // 具体的な型を使用
constructor(public value: T) {}
}
原因: 静的メンバーはクラス自体に属し、インスタンス生成前にアクセスされるため、型パラメータが確定しません。 解決策: 静的メンバーには具体的な型を使うか、静的メソッドにするなら別の型パラメータを定義しましょう。
❌ デフォルト型パラメータの順序を間違える
// ❌ デフォルトなしパラメータがデフォルトありの後にある
// interface BadResult<T = unknown, E> { ... } // Error
// ✅ デフォルトなしを先に
interface GoodResult<E, T = unknown> {
data?: T;
error?: E;
}
// または両方にデフォルトを設定
interface BetterResult<T = unknown, E = Error> {
data?: T;
error?: E;
}
原因: デフォルト値のない型パラメータは、デフォルト値のあるパラメータの後に配置できません。 解決策: 必須の型パラメータを先に、デフォルト付きを後に配置しましょう。
❌ extends句で型を間違える
// ❌ extendsの後に値を書いてしまう
// function log<T extends "string">(value: T): void { } // "string"はリテラル型
// 意図がstring型への制約なら
function logString<T extends string>(value: T): void {
console.log(value);
}
logString("hello"); // OK: Tは"hello"型
// logString(123); // Error: numberはstringに代入できない
原因: extends "string"はリテラル型"string"への制約であり、string型全体への制約ではありません。
解決策: extends stringのように、型名(小文字始まり)を使いましょう。
❌ Intersection制約で矛盾が生じる
interface A {
prop: string;
}
interface B {
prop: number;
}
// ❌ 矛盾する型のIntersection
type Impossible = A & B;
// propは string & number = never
function process<T extends A & B>(value: T): void {
// value.prop は never型(実質使用不可)
}
// ✅ 矛盾しない型のIntersection
interface C {
name: string;
}
interface D {
age: number;
}
type Possible = C & D; // { name: string; age: number; }
原因: 同名プロパティが異なる型を持つと、Intersectionでneverになります。
解決策: Intersection前に型の互換性を確認し、矛盾がないことを確かめましょう。
NoInfer<T>(TS 5.4+)
複数箇所で同じ型パラメータが使われるとき、特定の引数を型推論の基準から除外したい場面があります。TypeScript 5.4 で追加された NoInfer<T> はこの目的のためのユーティリティです。
問題: すべての引数が推論に使われる
// 色のリストとデフォルト色を受け取る関数
function createStreetLight<C extends string>(colors: C[], defaultColor: C) {
return { colors, defaultColor }
}
// defaultColor に 'blue' を渡すと...
const light = createStreetLight(['red', 'yellow', 'green'], 'blue')
// C が 'red' | 'yellow' | 'green' | 'blue' に広がってしまう(意図しない)
意図としては「colors に含まれる色をデフォルトにしたい」のですが、defaultColor も推論に使われるため、'blue' が含まれた Union に広がってしまいます。
解決: NoInfer で推論を抑制
// defaultColor は推論対象から除外
function createStreetLightV2<C extends string>(
colors: C[],
defaultColor: NoInfer<C>,
) {
return { colors, defaultColor }
}
createStreetLightV2(['red', 'yellow', 'green'], 'blue')
// ❌ Error: 'blue' は 'red' | 'yellow' | 'green' に割り当てられない
createStreetLightV2(['red', 'yellow', 'green'], 'red')
// ✅ OK
NoInfer<C> を付けた引数は、「型 C を満たすかチェック」はするが、「型 C の推論には関わらない」という動作になります。これにより、ライブラリ API の引数に意図しない値が混入するのを防げます。
NoInfer はライブラリ API の設計で特に有用です。読み手に対して「この引数は型推論ではなく型チェックの目的で C と宣言されている」と明示できます。