はじめに
この記事では、私が学習したTypeScriptの重要な概念である「ジェネリクス」と「ポリモーフィズム」について分かりやすく説明します。
この記事の対象者
- TypeScriptを学習中でジェネリクスについて理解を深めたい方
- ポリモーフィズムの詳細を知りたい方
開発環境
mac Ventura 13.3.1
Visual Studio Code
Typescript 5.0
Node.js version 18
ジェネリック型とは?
ジェネリック型とは、特定の型を指定せず、一時的に型を置き換えて再利用可能なコードを作成するための手段です。これはTypeScriptの強力な機能の一つで、型のポリモーフィズム(同一のインターフェイスに対する異なる振る舞い)を実現します。
なぜジェネリック型が必要か?
ジェネリクスが無ければ、関数やクラスが特定の型に依存してしまい、その型のデータしか扱えなくなってしまいます。ジェネリクスを使用することで、関数やクラスは一般的なデータ型を扱うことができ、その結果、コードの再利用性が大幅に向上します。
具体的に例を挙げましょう。
以下のコードは、関数をstring
かnumber
型の配列を受け取り、その配列の全要素を初期値に加算します。
function simpleReduce(array: number[] | string[], initialValue: number | string): number | string {
let result = initialValue;
for (let i = 0; i < array.length; i++) {
result += array[i];
}
return result;
}
この関数は単純で便利ですが、いくつかの問題があります。型は number[] | string[] と定義されているため、number 型の配列と string 型の初期値、またはその逆の組み合わせを関数に渡すことが可能です。
しかし、これは意図した動作ではありません。なぜなら、文字列と数値を直接結合することは型安全ではないからです。数値の配列と文字列の初期値を混在させると、数値が自動的に文字列に変換され、結果として文字列が返ります。
また、文字列の配列と数値の初期値を混在させると、数値が文字列に変換されるか、NaN(非数値)を引き起こす可能性があります。このような型の不一致はバグの原因となるため、プログラミングにおいては避けるべきです。
それでは、ジェネリクスを使ってこの問題を解決してみましょう。
1: まず、以下のようにジェネリック型 T を使った関数シグネチャを定義します。
type GenericReduce<T> = {
(array: T[], initialValue: T): T
}
2: 次に、この型を使用してジェネリック関数を定義します。
const genericReduce: GenericReduce<number> = (array: number[], initialValue: number): number => {
let result = initialValue;
for (let i = 0; i < array.length; i++) {
result += array[i];
}
return result;
}
これで、この関数は number 型の配列と number 型の初期値のみを受け取ることが保証されます。また、上記の例でGenericReduce<string>
のように型を変更すれば、同じ関数のロジックで string 型の配列を処理することも可能です。
ジェネリクス型パラメーターについて
ジェネリクス型パラメーターは、関数やクラスがジェネリクスを使用する際に必要となる型を受け取るための「仮の型」を指します。このパラメーターは、関数やクラスの定義中で使用され、具体的な型が与えられるまでその型を代表します。
GenericReduceのTはジェネリクス型パラメーターの一例です。ここでは、Tは型パラメーターとして機能し、GenericReduceがどのような型でも扱えるようにします。
ポリモーフィズムについて
ポリモーフィズムは、「多形性」とも訳され、あるエンティティ(ここでは、主に関数やメソッド)が複数の型に対応できる特性を指します。同じインターフェイスやメソッドが、異なる型に対して異なる振る舞いをすることを可能にします。TypeScriptのジェネリクスは、このポリモーフィズムを強力にサポートします。
GenericReduceの例では、同じ関数がstring型やnumber型など、任意の型に対応できるため、これはポリモーフィズムの一種と言えます。
Mapについて
さて、上記のジェネリクスの基本的な概念を理解した上で、より複雑な使用例を見てみましょう。以下はMap型とその使用例です。
前提
まず、次のようなシグネチャを定義します。
type Map<T, U> = (array: T[], fn: (item: T) => U) => U[]
このシグネチャは、T型の配列と、T型の要素をU型に変換する関数(fn)を受け取り、U型の配列を返す関数を定義しています。
文字列から数値へのマッピング
以下の関数は、文字列の配列と、文字列を数値に変換する関数を引数として受け取ります。そして、文字列の配列を数値の配列に変換して返します。
const mapStringsToNumbers: Map<string, number> = (array: string[], fn) => {
const result = []
for (let i = 0; i < array.length; i++) {
const item = array[i]
result[i] = fn(item)
}
return result
}
以下の関数は、文字列の配列['123', '456', '789']と、文字列を数値に変換する関数(item) => Number(item)
をmapStringsToNumbers
関数に渡しています。結果として、文字列の配列が数値の配列[123, 456, 789]に変換されます。
const numbers = mapStringsToNumbers(['123', '456', '789'], (item) => Number(item))
console.log('Generics advanced sample 1:', numbers)
数値から文字列へのマッピング
以下の関数も前述の関数と同様の構造を持っていますが、今回は数値の配列を文字列の配列に変換します。
const mapNumbersToStrings: Map<number, string> = (array: number[], fn) => {
const result = []
for (let i = 0; i < array.length; i++) {
const item = array[i]
result[i] = fn(item)
}
return result
}
以下のコードでは、前述のmapStringsToNumbers関数で生成された数値の配列と、数値を文字列に変換する関数(item) => String(item)
をmapNumbersToStrings
関数に渡しています。結果として、数値の配列が文字列の配列['123', '456', '789']に変換されます。
const strings = mapNumbersToStrings(numbers, (item) => String(item))
console.log('Generics advanced sample 2:', strings)
Map<T, U>
型は、T型の配列を受け取り、各アイテムをU型に変換する関数を提供します。つまり、これは一種の配列の変換操作を提供するジェネリック関数型と言えます。
mapStringsToNumbers
関数は、文字列の配列を数値の配列に変換します。そして逆に、mapNumbersToStrings
関数は、数値の配列を文字列の配列に変換します。これらの関数は、Map型の特性を活かして、異なる型間での変換を実現しています。
このように、TypeScriptのジェネリクスは、同じ構造を保ちながら様々な型を操作するための強力なツールです。これにより、型安全性を保ちつつ、コードの再利用性を向上させることが可能となります。