TypeScriptの学習を進める中で、特定の型に特化したコンポーネントを作成する機会が多くありました。しかし、異なるデータ型に対しても同じロジックを適用したい場合、毎回同じようなコードを記述するのは非効率的です。例えば、number型のアイテムを処理するリストコンポーネントと、string型のアイテムを処理するリストコンポーネントで、ほとんどコードが同じなのに型だけが違う、といったケースです。
このような状況で、コードの再利用性を高めつつ、TypeScriptの最大のメリットである「型安全性」を損なわずに汎用的なコンポーネントを作成するための強力な機能が「ジェネリクス」です。
本回では、ジェネリクスの基本的な概念から、関数、インターフェース、クラスでの具体的な利用方法、さらには型変数に制約を加える方法までを学びます。これにより、どんな型にも対応できる柔軟な、しかし安全なコンポーネントを作成できるようになることを目指します。
エンジニアなるほど、異なる型で同じロジックを何度も書くのは非効率的…TypeScriptでそれを解決する強力な機能、気になりますね!
ジェネリクスとは?
ジェネリクス(Generics)は、「型引数」や「型変数」とも呼ばれ、関数、インターフェース、クラスなどの定義時に、扱う型を特定の型に固定せず、後から利用する側が型を指定できるようにする機能です。これにより、異なる型に対して同じコードロジックを適用できるようになり、コードの再利用性が大幅に向上します。
イメージとしては、どんな中身でも入れられる「箱」のようなものです。箱自体は中身に依存しませんが、一度「文字列」を入れると決めたら、その箱は「文字列専用の箱」として扱われ、別の型を入れることができなくなります。これが型安全性を保つということです。
基本的な構文は、型変数を山括弧 <> で囲んで宣言し、その型変数を型として利用します。一般的な型変数名は T(Type)、K(Key)、V(Value)、E(Element)などが使われますが、任意の識別子を使用できます。
デザイナー型を後から決められる『箱』のイメージ、分かりやすいです!使う側が型を指定できる汎用性がジェネリクスなんですね。
1. 関数でのジェネリクス
まずは簡単な関数でジェネリクスを使ってみましょう。ある引数をそのまま返すidentity関数を考えます。
ジェネリクスを使わない場合、引数と返り値の型を動的に変更することはできません。anyを使えばどんな型でも扱えますが、型安全性が失われます。
// any を使うと型情報が失われる
function identityAny(arg: any): any {
return arg;
}
let numAny = identityAny(123); // numAny は any 型
let strAny = identityAny("hello"); // strAny は any 型
// any なので、存在しないプロパティにアクセスしてもエラーにならない
// console.log(strAny.length()); // 実行時エラーになる可能性がある
ここでジェネリクスを使うと、引数として受け取った型の情報をそのまま返り値の型として扱うことができます。
function identity<T>(arg: T): T {
return arg;
}
// 関数の呼び出し時に型を指定する (型推論が働くため省略可)
let output1 = identity<string>("myString"); // output1 の型は string
console.log(output1.length); // string 型なので length プロパティに安全にアクセスできる
let output2 = identity<number>(100); // output2 の型は number
// console.log(output2.length); // number 型には length プロパティがないためコンパイルエラー
let output3 = identity(true); // 型引数を指定しなくても、引数から型推論される (output3 の型は boolean)
identity<T>(arg: T): T の <T> が型変数です。これは「Tという仮の型」を意味します。関数が呼び出される際に、引数 arg の型が T に代入され、その T の型情報が関数のスコープ内で利用されます。
エンジニアanyだと型安全性が失われるけど、ジェネリクスなら引数の型情報をそのまま返り値にも活かせる。これで汎用性と型安全性を両立できるのか!
2. インターフェースでのジェネリクス
次に、インターフェースでジェネリクスを使用する方法を見てみましょう。異なる型のデータを格納できる汎用的なコンテナを定義する際に役立ちます。
// 任意の型の値を格納できる Box インターフェース
interface Box<T> {
value: T;
}
// string 型の Box を作成
let stringBox: Box<string> = { value: "TypeScript" };
console.log(stringBox.value.toUpperCase()); // string 型なので toUpperCase() が利用可能
// number 型の Box を作成
let numberBox: Box<number> = { value: 123 };
console.log(numberBox.value.toFixed(2)); // number 型なので toFixed() が利用可能
// オブジェクト型の Box を作成
interface User {
id: number;
name: string;
}
let userBox: Box<User> = { value: { id: 1, name: "Alice" } };
console.log(userBox.value.name); // User 型なので name プロパティにアクセス可能
Box<T> のようにインターフェース名に型変数を指定することで、そのインターフェースが保持するプロパティの型を柔軟に定義できます。
デザイナーインターフェースでも型変数を指定して、柔軟なデータ構造を定義できるんですね。汎用的なBoxインターフェースの使い方が理解できました!
3. クラスでのジェネリクス
クラスでもジェネリクスを使用できます。これにより、特定のデータ型に依存しない汎用的なデータ構造(例えば、スタックやキュー、リストなど)を実装できます。
ここでは、任意の型の要素を格納できるシンプルな Queue クラスを作成してみましょう。
class Queue<T> {
private data: T[] = [];
enqueue(item: T): void {
this.data.push(item);
}
dequeue(): T | undefined {
return this.data.shift();
}
peek(): T | undefined {
return this.data[0];
}
isEmpty(): boolean {
return this.data.length === 0;
}
}
// string 型のキューを作成
const stringQueue = new Queue<string>();
stringQueue.enqueue("Hello");
stringQueue.enqueue("TypeScript");
console.log(stringQueue.dequeue()); // "Hello"
console.log(stringQueue.peek()); // "TypeScript"
// number 型のキューを作成
const numberQueue = new Queue<number>();
numberQueue.enqueue(10);
numberQueue.enqueue(20);
console.log(numberQueue.dequeue()); // 10
numberQueue.enqueue(30);
console.log(numberQueue.dequeue()); // 20
class Queue<T> と定義することで、Queue クラスは T という型変数を受け入れます。これにより、data 配列、enqueue メソッドの引数、dequeue メソッドと peek メソッドの返り値が、インスタンス化時に指定された型 T になるようになります。
エンジニアクラスでもジェネリクスを使えば、例えばQueueのように、どんな型でも扱えるけど型安全なデータ構造が実装できる。これは便利だ!
4. ジェネリクス型変数への制約 (Constraints)
ジェネリクスは非常に強力ですが、型変数 T がどのようなプロパティを持っているかTypeScriptコンパイラにはわかりません。そのため、T 型の変数に対して特定のプロパティアクセスやメソッド呼び出しを行うとエラーになる場合があります。
例えば、要素の length プロパティを出力する関数を作りたいとします。
// これはエラーになる
// function printLength<T>(arg: T): void {
// console.log(arg.length); // Property 'length' does not exist on type 'T'.
// }
このような場合、「T は特定のインターフェースやクラスを継承している必要がある」という制約をジェネリクス型変数に加えることができます。これには extends キーワードを使用します。
例えば、length プロパティを持つ型に制約を加えるには、{ length: number } という構造を持つ型を継承するように指定します。
interface HasLength {
length: number;
}
// T は HasLength インターフェースを実装している必要がある
function printLength<T extends HasLength>(arg: T): void {
console.log(arg.length);
}
// string 型は length プロパティを持つのでOK
printLength("TypeScript"); // 10
// 配列も length プロパティを持つのでOK
printLength([1, 2, 3]); // 3
// HasLength を満たすオブジェクトもOK
printLength({ name: "hello", length: 5 }); // 5
// number 型は length プロパティを持たないのでコンパイルエラー
// printLength(123); // Argument of type 'number' is not assignable to parameter of type 'HasLength'.
// boolean 型も length プロパティを持たないのでコンパイルエラー
// printLength(true); // Argument of type 'boolean' is not assignable to parameter of type 'HasLength'.
T extends HasLength とすることで、T は HasLength インターフェースが持つ length プロパティを必ず持つことが保証されます。これにより、arg.length へのアクセスが安全になります。
この制約の機能は、APIから取得したデータや特定の属性を持つオブジェクトを扱う際に、非常に役立ちます。
デザイナーextendsキーワードで型変数に『特定のプロパティを持つこと』といった制約を加えられるんですね。これで、より具体的な操作を型安全に行えるようになるわけだ!
まとめ
今回は、TypeScriptで汎用的なコンポーネントを作成するための強力な機能である「ジェネリクス」について深く掘り下げました。
- ジェネリクスは、型に依存しない再利用可能なコードを作成するための仕組みです。
- 関数、インターフェース、クラスなど、様々な場所でジェネリクスを利用できます。
- 型変数に
extendsを用いて制約を加えることで、より安全かつ具体的な操作をジェネリクスなコード内で実現できます。
ジェネリクスを使いこなすことで、異なるデータ型に対しても同じロジックを型安全に適用できるようになり、あなたのTypeScriptコードはより柔軟で堅牢なものになるでしょう。ぜひ実際に手を動かして、様々な型で試してみてください。