日常コードのためのTypeScriptジェネリクス:実践ガイド
どんな型でも動作する関数を書いているけれど、型安全性を失いたくない。そんな時、anyを使うこともできますが、それはTypeScriptの目的に反します。ジェネリクスを使えば、複数の型で動作するコードを書きながら、型情報を保持できます。このガイドでは、物事を過度に複雑にせずに、日常のコードでジェネリクスを使う方法を紹介します。
ジェネリクスとは?
ジェネリクスは、単一の型ではなく、さまざまな型で動作する再利用可能なコンポーネントを作成する方法です。入力の型をキャプチャして出力で使用できるため、柔軟性を犠牲にすることなく型安全性を確保できます。
ジェネリクスを型パラメータと考えてみてください。関数が値パラメータを取るように、ジェネリック関数は型パラメータを取ります。例えば、identity関数:
function identity<T>(arg: T): T {
return arg;
}
ここで、Tは型パラメータです。identity<string>("hello")を呼び出すと、TypeScriptはTがstringであると推論し、戻り値の型はstringになります。数値を渡すと、数値を返します。これはanyを使うよりも正確です。
なぜanyを使わないのか?
anyを使うと型チェックが無効になります。自動補完、リファクタリングのサポートが失われ、コンパイラは間違いを検出できません。ジェネリクスは型情報をコード全体に流し続けるため、柔軟性と安全性の両方を得られます。
実践的なジェネリック関数
よくあるシナリオを見てみましょう:配列の最初の要素を返す関数です。
function firstElement<T>(arr: T[]): T | undefined {
return arr[0];
}
ジェネリクスを使わない場合、function firstElement(arr: any[]): anyと書くかもしれませんが、これでは型が失われます。ジェネリクスを使えば、string[]を渡すと、戻り値の型はstring | undefinedになります。
複数の型パラメータを使うこともできます。例えば、コールバックを使って配列を新しい配列にマップする関数:
function map<T, U>(arr: T[], fn: (item: T) => U): U[] {
return arr.map(fn);
}
これは本質的に、TypeScriptの標準ライブラリでArray.prototype.mapが型付けされている方法です。
ジェネリックインターフェースと型エイリアス
ジェネリクスは関数に限定されません。再利用可能なデータ構造を定義するために、ジェネリックインターフェースや型エイリアスを作成できます。
interface Box<T> {
value: T;
}
const stringBox: Box<string> = { value: "hello" };
const numberBox: Box<number> = { value: 42 };
これはAPIレスポンス、状態管理、または任意のコンテナ型に役立ちます。
デフォルト型を提供することもできます:
interface ApiResponse<T = unknown> {
data: T;
status: number;
}
これで、型引数なしのApiResponseはデフォルトでunknownになります。
ジェネリック制約
ジェネリクスで使用できる型を制限したい場合があります。extendsキーワードを使って制約を追加できます。
function getLength<T extends { length: number }>(arg: T): number {
return arg.length;
}
これでTはlengthプロパティを持たなければなりません。getLength("hello")やgetLength([1,2,3])は呼び出せますが、getLength(42)は呼び出せません。
制約は、共通の形状を共有するオブジェクトを扱うときに強力です。
クラスでジェネリクスを使う
クラスもジェネリックにできます。一般的な例はジェネリックコレクションです:
class Stack<T> {
private items: T[] = [];
push(item: T): void {
this.items.push(item);
}
pop(): T | undefined {
return this.items.pop();
}
}
const stack = new Stack<number>();
stack.push(1);
stack.push(2);
const popped = stack.pop(); // number | undefined
これにより、数値スタックには数値のみをプッシュできることが保証されます。
実世界の例:型安全なイベントエミッター
ジェネリクスを使ってイベント名をペイロード型にマップする、シンプルなイベントエミッターを構築しましょう。
type EventMap = Record<string, any>;
class EventEmitter<T extends EventMap> {
private listeners: { [K in keyof T]?: Array<(payload: T[K]) => void> } = {};
on<K extends keyof T>(event: K, listener: (payload: T[K]) => void): void {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event]!.push(listener);
}
emit<K extends keyof T>(event: K, payload: T[K]): void {
this.listeners[event]?.forEach(listener => listener(payload));
}
}
// 使用法
interface MyEvents {
login: { userId: string };
logout: { userId: string };
}
const emitter = new EventEmitter<MyEvents>();
emitter.on('login', (payload) => {
console.log(payload.userId); // string
});
emitter.emit('login', { userId: '123' }); // OK
// emitter.emit('login', { userId: 123 }); // エラー
このパターンは、イベント名とペイロードの完全な型安全性を提供し、実行時エラーを減らします。
よくある落とし穴とベストプラクティス
- ジェネリクスを過度に使わない。 関数が特定の型でのみ動作するなら、ジェネリックにしないでください。
- 説明的な型パラメータ名を使う。 単純なケースでは
Tで十分ですが、TItemやTResponseの方が明確な場合があります。 - 実行時エラーを避けるために制約を優先する。 プロパティが必要なら、制約を追加してください。
- 型推論を活用する。 多くの場合、型引数を明示的に渡す必要はありません。TypeScriptが推論できます。
比較:ジェネリクス vs any vs ユニオン型
| アプローチ | 型安全性 | 柔軟性 | ユースケース |
|---|---|---|---|
| ジェネリクス | 高 | 高 | 型を保持した再利用可能なコンポーネント |
any |
なし | 高 | 迅速なプロトタイピング、JSからの移行 |
| ユニオン型 | 中 | 中 | 既知の型の限られたセット |
ジェネリクスは柔軟性と安全性のバランスを取るため、ライブラリや共有ユーティリティに最適です。
FAQ
ユニオン型の代わりにジェネリクスをいつ使うべきですか?
入力と出力の間の型関係が重要な場合にジェネリクスを使います。ユニオン型は、値が固定された型のセットのいずれかであり、特定の型を操作全体で保持する必要がない場合に使用します。
アロー関数でジェネリクスを使えますか?
はい、ただし.tsxファイルでは構文がトリッキーになることがあります。const identity = <T,>(arg: T): T => arg;のように、JSXの曖昧さを避けるために末尾にカンマを付けて書けます。
ジェネリクスをオブジェクトのみを受け入れるように制約するには?
T extends objectまたはT extends Record<string, unknown>を使って、Tがオブジェクト型であることを保証します。
ジェネリクスはTypeScriptの型システムの基礎です。日常のコードでこれらを使うことで、柔軟かつ安全な関数、インターフェース、クラスを書くことができます。シンプルなジェネリック関数から始め、必要に応じて制約やより複雑なパターンを徐々に取り入れていきましょう。その他の開発者ツールについては、JSON Formatterをチェックして、JSONデータを迅速に検証およびフォーマットしてください。