TypeScript ジェネリックと keyof で型の安全性と再利用性を高める
TypeScriptで作業していると、様々な型に対応できる再利用可能なコードを書きたくなるときがあります。まさにこのようなときにジェネリック(Generics)が必要です。そして、keyof 演算子と組み合わせて使うことでオブジェクトのプロパティを安全に扱うことができます。本稿では TypeScript のジェネリックと keyof を併用する方法を、実用的な例とともに見ていきましょう。基本概念から実務で即適用できるパターンまで、段階的に理解できるよう構成しました。
TypeScript ジェネリックの基礎
ジェネリックは再利用可能なコンポーネントを作るための重要な道具であり、様々な型に対応しつつ型の安全性を確保します。一つの型に限定せず、さまざまな型に対応できるコンポーネントを作ることができます。最も基本的な例、identity 関数を見てみましょう。
function identity<T>(arg: T): T {
return arg;
}
ここで <T> は型変数です。関数を呼び出す際に渡される型をキャプチャし、入力と出力の型を一致させます。型変数の名前は自由に定められますが、慣習的に T(Type の略)を使うことが多いです。
ジェネリック関数の呼び出し方
ジェネリックは明示的な型引数または型推論で呼び出せますが、可能であれば型推論を推奨します。ジェネリック関数は次の2つの方法で呼び出せます。
明示的に型引数を渡す場合:
let output = identity<string>("myString");
型推論を活用する場合:
let output = identity("myString");
後者の方法のほうが簡潔で可読性が高く、TypeScript が自動で型を推論できるならこちらの方式を推奨します。
型変数がもたらす柔軟性
ジェネリックは様々な型を安全に受け入れ、不一致な型の組み合わせはコンパイル時にブロックします。
function toArray<T>(a: T, b: T) {
return [a, b];
}
toArray<string>("hello", "world"); // string[] 型
toArray<number>(1, 2); // number[] 型
もし異なる型の引数を渡すと、コンパイル時にエラーが発生します:
toArray<string>("hello", 1); // 型エラー!
keyof 演算子でジェネリックに制約を加える
keyof はオブジェクト型のキーのユニオンを提供し、ジェネリック制約と組み合わせることでプロパティアクセスを型安全にします。keyof 演算子はオブジェクト型のキーをリテラル型のユニオンとして取り出します。これをジェネリックと一緒に使うことで強力な型制約を作ることができます。
keyof の基本動作
interface Person {
name: string;
age: number;
}
type PersonKeys = keyof Person; // "name" | "age"
ジェネリック制約で型の安全性を確保する
Key extends keyof Type 制約を通じて存在しないプロパティのアクセスをコンパイル段階でブロックできます。
function getProperty<Type, Key extends keyof Type>(obj: Type, key: Key) {
return obj[key];
}
const person = {
name: "Anna",
age: 30
};
getProperty(person, "name"); // ✅ 正常動作
getProperty(person, "email"); // ❌ 型エラー!
重要なヒント
ジェネリック制約は「使用を許可するキーの集合」を型として強制します。つまり、存在しないキーはそもそも関数の引数として許可されません。
プロパティ設定関数を実装する
T[K] のインデックスアクセス型を使えば、値の型まで正確に制約できます。
function setProperty<T, K extends keyof T>(obj: T, key: K, value: T[K]): void {
obj[key] = value;
}
const user = {
name: "John",
age: 25
};
setProperty(user, "name", "Jane"); // ✅ 正常
setProperty(user, "name", 30); // ❌ 型エラー! (string型でなければならない)
setProperty(user, "email", "test@test.com"); // ❌ 型エラー! (存在しないプロパティ)
ジェネリックインターフェースとクラス
関数だけでなくインターフェースとクラスにもジェネリックを適用して、柔軟な API を設計できます。
ジェネリックインターフェース
interface GenericIdentityFn<Type> {
(arg: Type): Type;
}
function identity<Type>(arg: Type): Type {
return arg;
}
let myIdentity: GenericIdentityFn<number> = identity;
ジェネリッククラス
class GenericNumber<NumType> {
zeroValue: NumType;
add: (x: NumType, y: NumType) => NumType;
}
let myGenericNumber = new GenericNumber<number>();
myGenericNumber.zeroValue = 0;
myGenericNumber.add = function(x, y) {
return x + y;
};
実務例:
class User<P> {
constructor(public payload: P) {
this.payload = payload;
}
getPayload() {
return this.payload;
}
}
const user1 = new User<{ name: string; age: number }>({
name: "Kim",
age: 28
});
const user2 = new User<string[]>(["admin", "user"]);
ジェネリックの制限事項
TypeScript ではジェネリック enum と namespace は作成できません。これは TypeScript の設計上の制限です。
デフォルト型パラメーター
ジェネリックにデフォルトの型を指定すると、API の使いやすさが向上し、一般的なケースを簡潔に処理できます。
function createHTMLElement<T extends HTMLElement = HTMLDivElement>(
element?: T
): T {
return element || (document.createElement('div') as T);
}
// 型引数を明示しなければ HTMLDivElement が使用されます
const div = createHTMLElement();
// 明示的に別の型を指定することも可能です
const button = createHTMLElement<HTMLButtonElement>(
document.createElement('button')
);
実践例:型安全なデータアクセスパターン
API レスポンスから特定のプロパティを安全に取り出すユーティルを作れば、ランタイムのエラーをコンパイルタイムに防げます。
interface ApiResponse {
data: {
user: {
id: number;
name: string;
email: string;
};
posts: Array<{
id: number;
title: string;
}>;
};
meta: {
timestamp: number;
version: string;
};
}
function extractData<T, K extends keyof T>(response: T, key: K): T[K] {
return response[key];
}
const response: ApiResponse = {
data: {
user: { id: 1, name: "Lee", email: "lee@example.com" },
posts: [{ id: 1, title: "First Post" }]
},
meta: {
timestamp: Date.now(),
version: "1.0"
}
};
const data = extractData(response, "data"); // data の型が自動で推論される
const meta = extractData(response, "meta"); // meta の型も正確に推論される
ネストされたプロパティにアクセスする
ネストしたキーを段階的に制約すれば、深いパスのアクセスも安全にできます。
function getNestedProperty<
T,
K1 extends keyof T,
K2 extends keyof T[K1]
>(obj: T, key1: K1, key2: K2): T[K1][K2] {
return obj[key1][key2];
}
const userName = getNestedProperty(response, "data", "user"); // user オブジェクトの型
ユニオン型とジェネリック制約
extends でユニオンを指定すれば、許可する型を正確に限定できます。
function hello<T extends string | number>(msg: T): T {
return msg;
}
hello(3); // ✅ number 型
hello("hi"); // ✅ string 型
hello([3, 5]); // ❌ 型エラー! (配列は許可されない)
keyof と typeof を併用する
定数オブジェクトから値のユニオン型を抽出する実務パターンは、keyof と typeof の組み合わせで実現します。
const SUBJECT = {
Math: 'Math',
English: 'English',
Science: 'Science'
} as const;
type Subject = typeof SUBJECT[keyof typeof SUBJECT];
// "Math" | "English" | "Science" 型
function getSubjectName(subject: Subject): string {
return subject;
}
getSubjectName("Math"); // ✅ 正常
getSubjectName("History"); // ❌ 型エラー!
このパターンは以下のように動作します:
-
typeof SUBJECTでオブジェクト型を取得 -
keyof typeof SUBJECTでキー群(“Math”,“English”,“Science”)を抽出 -
typeof SUBJECT[keyof typeof SUBJECT]で値群のリテラル型をユニオンとして形成
ジェネリックと keyof の実務活用メリット
型の安全性向上:コンパイル時に存在しないプロパティアクセスを防げます。
コード再利用性:一つの関数やクラスを様々な型に対して再利用できます。
保守性改善:型が明確なのでリファクタリング時の安全網となります。
IDE サポート強化:自動補完と型ヒントが正確に機能します。
実務のヒント
動的キーアクセスユーティル(例:getProperty、setProperty、getNestedProperty)を作っておくとプロジェクト全体で再利用でき、ランタイムエラーをコンパイルタイムへ移せます。
よくある質問 (FAQ)
ジェネリック型変数はいくつまで使えますか?
必要なだけ使えますが、過度な型変数は可読性を損ねるため通常は2~3個が適切です。
function transform<T, U, V>(input: T, mapper1: (x: T) => U, mapper2: (x: U) => V): V {
return mapper2(mapper1(input));
}
keyof はいつ使うのが良いですか?
動的プロパティアクセス、マッピング/変換ユーティル、型安全なイベントハンドラ、状態管理などでプロパティの有効性を保証したいときに有用です。オブジェクトのプロパティへ安全にアクセスする必要があるときに特に威力を発揮します。
ジェネリック制約があまりにも複雑になったらどうすれば良いですか?
型エイリアスで制約を分離して可読性を上げましょう。
type ValidKey<T> = keyof T;
type ValidValue<T, K extends ValidKey<T>> = T[K];
function update<T, K extends ValidKey<T>>(
obj: T,
key: K,
value: ValidValue<T, K>
): void {
obj[key] = value;
}
プロジェクトに適用してみてください
ジェネリックと keyof は型安全性とコード品質を大きく高める強力なツールです。小さなユーティルから適用範囲を広げてみましょう。実務でオブジェクトのプロパティにアクセスするコードがあれば、その箇所こそジェネリックと keyof を適用する良いスタートポイントです。コードを書いていて「このプロパティが本当に存在しているか自信がない」と感じたら、そのタイミングこそジェネリック制約を追加するタイミングです。TypeScript の型システムがあなたのコードをより安全にしてくれます :)