はじめに
4月からTypeScriptを初めて使いだしてから一ヶ月ほど経ちますが、いまだに以下のようなコードに馴染めないでいます。
function reverseArray<T>(arr: T[]): T[] {
return arr.reverse();
}
const numbers = [1, 2, 3];
const reversedNumbers = reverseArray<number>(numbers);
console.log(reversedNumbers);
これは「ジェネリクス」と呼ばれる書き方を使っています。
本記事はジェネリクスについての基礎から丁寧に確認する記事となります。
ジェネリクスとは
ジェネリクスの概要
ジェネリクスは再利用できるコンポーネントを作るための機能です。
特定の型ではなく、様々な型で動作するコンポーネントを作成します。
TypeScript独自の機能ではなく、C#やJavaなど他の言語にもジェネリクスは存在します。
ジェネリクスは関数のほか、クラス、インターフェース、型エイリアスでも使用することができます。
ジェネリクスの定義方法
ジェネリクスを使用するには定義時に<>
を使用します。
冒頭で示したコードを使って確認します。
function reverseArray<T>(arr: T[]): T[] {
return arr.reverse();
}
const numbers = [1, 2, 3];
const reversedNumbers = reverseArray<number>(numbers);
console.log(reversedNumbers); // [ 3, 2, 1 ]
関数でジェネリクスを使用する場合は、引数のかっこの前に<>
を使います。
<>
内に指定する文字列は自由ですが、T
を指定するのが慣例のようです。また、他にはK
、V
、E
が使われるようです。
(おそらく型のType、キーのKey、値のValue、要素のElement)
指定した文字列を関数内で型として扱えます。
例示ではT型の配列を引数として受け取り、T型の配列を戻り値として返すようにしています。
関数を使用する場合には、定義するときと同じように引数のかっこの前に<>
を使います。
関数の中で使用したい型を指定します。
例示ではnumber
型を扱いたいので、number
を指定しています。
なお、型推論ができる場合には関数呼び出し時の<>
を省略することができます。
ジェネリクスの意味
ジェネリクスを使わない場合、どのようになるかを考えます。
function reverseNumArray(arr: number[]) {
return arr.reverse();
}
function reverseStrArray(arr: string[]) {
return arr.reverse();
}
const numbers = [1, 2, 3];
const strings = ["a", "b", "c"];
const reversedNumbers = reverseNumArray(numbers);
const reversedStrings = reverseStrArray(strings);
console.log(reversedNumbers); // [ 3, 2, 1 ]
console.log(reversedStrings); // [ 'c', 'b', 'a' ]
本来はわざわざ関数を作るほどの処理ではないのですが、理解を深めるためにこのような処理とします。
引数には型を指定する必要があるため、数値用の処理、文字列用の処理とそれぞれ別に定義しています。
やっていることは変わらないのに、型の分だけ処理を用意するのは冗長です。
上記を避けるため、以下のように処理を定義することもできます。
function reverseAnyArray(arr: any[]) {
return arr.reverse();
}
const numbers = [1, 2, 3];
const strings = ["a", "b", "c"];
const reversedNumbers = reverseAnyArray(numbers);
const reversedStrings = reverseAnyArray(strings);
console.log(reversedNumbers); // [ 3, 2, 1 ]
console.log(reversedStrings); // [ 'c', 'b', 'a' ]
引数の型をany
型としています。
一見これは良さそうに思えますが、戻り値として返されるのはany
型の配列です。
any
型は型チェックが行われないため、思わぬバグの原因となったり、IDEでの型補完の恩恵を受けられなくなったりします。
そこで登場するのがジェネリクスです。
ジェネリクスを使うことで、型を引数のようにして関数に受け渡す事ができます。
関数の定義時にはT
型として型の定義は柔軟なものにしています。
そして実行時にどの型で実行するのかを指定します。
function reverseArray<T>(arr: T[]): T[] {
return arr.reverse();
}
const numbers = [1, 2, 3];
const strings = ["a", "b", "c"];
const reversedNumbers = reverseArray<number>(numbers);
const reversedStrings = reverseArray(strings);
console.log(reversedNumbers); // [ 3, 2, 1 ]
console.log(reversedStrings); // [ 'c', 'b', 'a' ]
様々なジェネリクス
アロー関数
アロー関数でジェネリクスを使用する場合、ほとんど関数でジェネリクスを使用するのと変わりません。
const reverseArray = <T>(arr: T[]): T[] => {
return arr.reverse();
};
引数のかっこの前に<>
で型を定義するだけです。
クラス
クラスでジェネリクスを使用する場合は、クラス名のあとに<>
を使います。
以下は任意の型を受け取るスタックを定義した例です。
class Stack<T> {
private items: T[] = [];
push(item: T): void {
this.items.push(item);
}
pop(): T | undefined {
return this.items.pop();
}
}
const numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);
console.log(numberStack.pop()); // 2
const stringStack = new Stack<string>();
stringStack.push("Hello");
stringStack.push("World");
console.log(stringStack.pop()); // "World"
T
型の配列をプライベート変数として用意し、T
型の値を受け取るpush
メソッド、T
型の値を返すpop
メソッドを定義しています。
これにより、数値型や文字列型などそれぞれの型にあわせたスタックを作成することができます。
インターフェース
インターフェースでジェネリクスを使用する場合、インターフェース名のあとに<>
を使います。
以下はあるオブジェクトを作成できるインターフェースを定義した例です。
interface Pair<K, V> {
key: K;
value: V;
}
const pair1: Pair<string, number> = {
key: "count",
value: 42,
};
const pair2: Pair<number, boolean> = {
key: 123,
value: true,
};
key
というプロパティとvalue
というプロパティを定義しています。
それぞれK
型とV
型であることを指定しています。
ジェネリクスでは、カンマ区切りで複数の型パラメータを指定することができます。
文字列と数値、数値と真偽値など任意の組み合わせでkey
とvalue
の型を定義することができます。
型エイリアス
型エイリアスで使用する場合、エイリアス名のあとに<>
を使います。
以下は任意の型に対してnullを許容する型エイリアスを定義した例です。
type Nullable<T> = T | null;
const value1: Nullable<string> = "Hello";
const value2: Nullable<number> = null;
T
型またはnull型であるNullable
を定義することで、任意の型に対してnullを入れてもエラーにならない型を作成することができます。
ジェネリクスへの制約
ジェネリクスを使用することで型の定義を柔軟にすることができますが、自由すぎると逆に困る場合もあります。
そこで、ジェネリクスには制約を設けることができます。
以下は、extends
キーワードを使い、ジェネリクスに対してあるプロパティを持つことを強制する例です。
interface Lengthwise {
length: number;
}
function getLength<T extends Lengthwise>(obj: T): number {
return obj.length;
}
const str = "Hello";
console.log(getLength(str)); // 5
const arr = [1, 2, 3];
console.log(getLength(arr)); // 3
const num = 42;
console.log(getLength(num)); // エラー
引数で指定した値のlength
を定義する関数を作成しました。
引数の型はT
型としていますが、<>
内でLengthwise
インターフェースをextends
しています。
これにより、T
型は任意の型ではありますが、number
型のlength
プロパティをもっている必要があります。
文字列や配列はlength
プロパティをもっていますが、数値はもっていません。
そのため、数値を引数に指定した場合はエラーとなります。
このように制約を設けることで、ある程度型に柔軟性をもたせつつ、型安全な処理を作成することができます。
まとめ
ジェネリクスの概要から、いろいろなところで使えるジェネリクスについて確認しました。
何となく何をしているかわからず、苦手意識がありましたが、要は引数のように型を受け渡すことで、処理を柔軟にしているものだ、ということが理解できました。