下記の内容は主に、Generics · TypeScript Deep Diveから引用しながら、自分なりに理解したメモです。
なぜジェネリクスを使うのか
ジェネリクスの主な動機は、メンバー間に型の制約を提供すること。
メンバーとは具体的には以下の通り:
- クラスインスタンスメンバー
- クラスメソッド
- 関数の引数
- 関数戻り値
ジェネリクス良い点
- 抽象的な型がつくれる
- 実行時に、「思ってた型とちがう…」という事態を防ぐ
具体的にどういったことなのか、以下のコードを例に見ていく
class Queue {
private data = [];
push(item) { this.data.push(item); }
pop() { return this.data.shift(); }
}
これだとどんな型でもキューに出し入れできてしまう
しかがって、以下のような問題が発生する
class Queue {
private data = [];
push(item) { this.data.push(item); }
pop() { return this.data.shift(); }
}
const queue = new Queue();
queue.push(0);
queue.push("1");
console.log(queue.pop().toPrecision(1));
console.log(queue.pop().toPrecision(1)); // ランタイムエラー
toPrecision()
はstring型にはないメソッドなので、キューからstring型の、"1"
を取り出して、実行しようとすると、ランタイムエラーが発生してしまう。
この場合、キューにはstring型のみが追加されているという制約がほしい。(そうでなければ、string型でありますように…と祈ることしかできない)
この解決策として、これらの制約のためだけに特別なクラスを作成する方法がある。
class Queue {
protected data = [];
push(item) { this.data.push(item); }
pop() { return this.data.shift(); }
}
class QueueNumber extends Queue {
push(item: number) { super.push(item); }
pop(): number { return this.data.shift(); }
}
const queue = new QueueNumber();
queue.push(0);
queue.push("1"); // "1"はstring型なのでエラー
しかし、この方法だと、新しく型が増えるたびに、クラスを作成しなければならないので、つらい。
(例えばstring型用のキューがほしいと思えば、QueueStringクラスを作成しなければならなくなってしまう。)
しかし、本当に欲しいのは、pushされたものは、popされたものと同じ型であるという制約である。
そこで、ジェネリクスを使うと以下のように書くことができる。
class Queue<T> {
private data = [];
push(item: T) { this.data.push(item); }
pop(): T | undefined { return this.data.shift(); }
}
const queue = new Queue<number>();
queue.push(0);
queue.push("1"); // "1"はstring型なのでエラー
T
は、型パラメータ (type parameter) で、型を決定する引数になっている。
以下のように、型パラメータを宣言し、バインドしている。
型パラメータの宣言
class Queue<T> { }
型パラメータへのバインド(このときに、Tの型が決定している)
new Queue<number>();
ジェネリクスを使うことで、キューにはnumber型が追加されていることを想定して使うことができるようになった。
冒頭でも書いたように、ジェネリクスには以下のような良い点があることがわかる。
抽象的な型がつくれる
new QueueNumber();
new QueueString();
としなくて済む
実行時に、「思ってた型とちがう…」という事態を防ぐ
number型を想定して、queue.pop().toPrecision(1);
などとしたときに、予期しない実行時エラーを防ぐことができる。
ちなみに、T
は一般の引数と同様、なんでも良い。
(いきなり、T
とか出てきても、ぜひ怖がらないでほしい)
一般的に使用されている型パラメータ名は下記のようなものがある。:
- E - 要素(Element)
- K - キー (Key)
- N - 数 (Number)
- T - タイプ (Type)
- V - 値 (Value)
メンバ関数でもジェネリクスを使うことができる
function reverse<T>(items: T[]): T[] {
var toreturn = [];
for (let i = items.length - 1; i >= 0; i--) {
toreturn.push(items[i]);
}
return toreturn;
}
var sample = [1, 2, 3];
var reversed = reverse(sample);
console.log(reversed); // 3,2,1
// 安全!
reversed[0] = '1'; // 要素にstring型を代入できない
reversed = ['1', '2']; // 各要素がstring型の配列を代入できない
reversed[0] = 1; // Okay
reversed = [1, 2]; // Okay
上記のように、関数の引数と戻り値の型が同一であるという制約が得られる。
(この場合はT
型の配列)
ちなみに、上記のコードでは、型パラメータに対して、型を宣言(reverse<number>(sample);
)していないが、型推論によって、引数の型が決定している。
デザインパターン:便宜的なジェネリクス
以下の関数を考えてみる:
declare function parse<T>(name: string): T;
この場合、その型Tは一箇所でしか使われていないことがわかる。したがって、メンバー間の制約はない。これは型安全の点からみると、型アサーションも同然ということになる。
(つまり、以下のコードと同じようなこと)
declare function parse(name: string): any;
const something = parse('something') as TypeOfSomething;
これは、APIに利便性を提供することができるということでもある。
例えば、jsonレスポンスをロードする関数
getJSON<LoadUsersResponse>
(明示的に欲しい型のアノテーションをつける必要があるが、型推論があるので、戻り値の型を宣言しなくてよくなるため、記述量が多少減る)
type LoadUsersResponse = {
users: {
name: string;
email: string;
}[];
}
function loadUsers() {
return getJSON<LoadUsersResponse>({ url: 'https://example.com/users' });
}
戻り値としてのPromise <T>
は、Promise <any>
のような選択肢よりも優れている。
型を諦めない心は、たいせつ。
declare function send<T>(arg: T): void;
T
は、引数にマッチさせたい型を宣言するために使うこともできる。
以下のコードでいうと、{ x:123 }
がsomething型と違ったら怒ってくれるということ。
send<Something>({t
x:123,
});