altJS、すなわち、JavaScriptの代わりとなる言語の筆頭であるTypeScript。そのTypeScriptは、言語名が示す通り、JavaScriptにType、つまり、型の概念を持ち込んだものです。本連載では、このTypeScriptのType(型)に関して、さまざまな方向から紹介していきます。前回は、クラスの型に関するあれこれを紹介する最後として、クラス内での特殊なthisの使い方、抽象クラス、staticを紹介しました。今回は、クラスや関数を利用する段階で型の指定ができるジェネリクスについて、あれこれ紹介していきます。
この記事は会員限定です。会員登録(無料)すると全てご覧いただけます。
本連載ではジェネリクス(Generics)を既に利用しています。例えば、第2回のリスト5では、「Array<number>」という形で登場し、第4回のリスト6では、「Set<number>」という形で登場しています。その際は特に解説を加えていませんでした。そこで、ジェネリクスがどのようなものかということから、今回は話を始めていきます。
先ほど例に挙げた第4回のリスト6にある「Set<number>」では、「< >」内でデータ型を記述しています。ここではnumberを指定していますが、プリミティブ型だけでなく、さまざまなデータ型を記述できます。このデータ型を記述することの意味は、利用する段階で利用する側がデータ型を指定できるということです。
「ジェネリクス」(Generics)という名称は、他に、「ジェネリック」(Generic)や「ジェネリック型」(Generic Type)など、さまざまな呼び方があります。内容的にはほぼ同じものを指しますが、プログラミング言語やドキュメントによって違ってきます。本稿では、TypeScriptの公式ドキュメントが、そのタイトルで「Generics」という表記を採用しているため、「ジェネリクス」とします。
例に挙げたSetオブジェクトは、JavaScriptの組み込みオブジェクトの一つであり、集合を表します。配列と同様に、複数のデータをまとめるオブジェクトです。ただし配列とは違い、保持しているデータに重複がない状態を実現します。ということは配列と同様に、各要素にはどのようなデータを格納するのか、つまり各要素のデータ型が何であるかが大切です。そして、そのようなデータ型の指定は、Setを利用する段階でないと決定できません。そこで登場するのが、「<number>」という記述です。
このように、あるオブジェクトが内部で利用するデータの型を、利用する段階で指定する仕組みがジェネリクスであり、「< >」内にその型を記述します。例えば、リスト1の(1)のコードを記述すると、各要素がnumber型のSetオブジェクトとなります。
const numberSet = new Set<number>(); // (1) numberSet.add(56); // (2) numberSet.add(48); // (3) numberSet.add("こんにちは"); // (4)
このnumberSetには、リスト1の(2)や(3)のように数値データは格納できますが、(4)の文字列データは格納できず、図1のエラーとなります。
これが逆に、リスト2のようなジェネリクスの型指定でSetオブジェクトをnewした場合、(2)と(3)はエラーとなり、(4)はエラーとなりません(図2)。
const numberSet = new Set<string>();
このように利用する段階で型を指定でき、型に関してエラーの起きない(型安全な)コーディングのできる仕組みが、ジェネリクスです。
もちろん、リスト3のようにジェネリクスの型を指定せずにSetオブジェクトを利用することもできます。この場合は図3のように、数値も文字列も格納できてしまいます。
const numberSet = new Set();
このように、ジェネリクスの型を指定しない方がエラーとならず便利なように思えますが、逆に危険です。というのは、配列にしてもSetにしても、同種のデータをまとめておくためのものです。これらのオブジェクトに、数値と文字列のように異種のデータを混在させると、その存在意義に反するだけでなく、バグの原因となります。
そこでTypeScriptには、そのための安全網が設けられています。図3にあるように、ジェネリクスの型を指定しなかった場合、自動的にunknown型として扱われます。第1回で紹介したように、unknown型の場合は、その変数のデータをまともに操作できません。例えば、ジェネリクスの型を指定しなかったリスト3のnumberSetを、リスト4のようにループ処理した場合、図4のエラーとなります。
for (const element of numberSet) { element * 2; }
これは、各要素を表す変数elementがunknown型となり、unknown型は数値演算ができないために起きるエラーです。もちろん、リスト1の(1)のようにジェネリクスにnumber型を指定したnumberSetではエラーとなりません。
ジェネリクスの型指定は1つとは限りません。複数記述することもでき、その場合は、カンマで区切ります。例えば、Setと同じくJavaScriptの組み込みオブジェクトに、Mapがあります。これは連想配列を実現するオブジェクトで、キーと値のペアでデータを管理します。ということはMapを利用する場合、リスト5の(1)のようにジェネリクスの型指定に2個のデータ型を記述する必要があるということです。
const countList = new Map<string, number>(); // (1) countList.set("A", 34); // (2) countList.set("B", 33); // (2) countList.set("C", 35); // (2) countList.set(45, 54); // (3) countList.set("D", "E"); // (4)
この場合、1つ目のstringがキーを表し、2つ目のnumberが値を表します。そのため(2)のように、set()メソッドでデータを登録する際は、第1引数は文字列を、第2引数は数値を渡す必要があります。(3)や(4)のように、どちらか片方だけでも違うデータ型の値を渡すと、図5のようにエラーとなります。
前項で複数ジェネリクスの例として挙げたMapでは、1つ目の型指定がキーで、2つ目の型指定が値と紹介しました。これは、どのように決まっているかというと、Mapのクラス定義がそのようになっているからです。このように、クラスを定義する際に、ジェネリクスを組み込むことが可能です。この節では、ジェネリクスをクラス定義などに組み込む方法を紹介していきます。
ジェネリクスをクラス定義に組み込む方法を示す前に、具体例を紹介します。これは、リスト6のようになります。
class TypePrinter<T> { // (1) private _param: T; // (2) constructor(param: T) { // (3) this._param = param } showType(): void { const paramType = typeof this._param; console.log(`フィールドのデータ型: ${paramType}`); } } const printerNum = new TypePrinter<number>(35); // (4) printerNum.showType(); const printerStr = new TypePrinter<string>("こんにちは"); // (5) printerStr.showType();
このコードの実行結果は、以下のようになります。
フィールドのデータ型: number フィールドのデータ型: string
(1)でクラス名に続いて「< >」が記述されています。これがクラスでジェネリクスを利用できるように定義している部分であり、このような記述があるクラスを利用する場合、(4)や(5)のように、newする際に型を指定する必要があります。(4)ではnumberを、(5)ではstringを指定しています。
そして、このように指定されたデータ型が、クラス内では「< >」内に定義された文字(列)として利用できます。この「< >」内の定義を、「ジェネリクス型パラメーター」といいます。(1)では「T」としているので、(2)のフィールドである_paramのデータ型としてTを指定したり、(3)のコンストラクタの引数のparamのデータ型としてTを指定したりできます。
ジェネリクス型パラメーター名として、リスト6の(1)ではTの1文字としています。これは、型を表すTypeの頭文字であるTを表しています。このように、ジェネリクス型パラメーターとしては、特に理由がない場合は1文字とすることが多いです。しかし、この限りではありませんし、どのような文字列でも構いません。TypeScriptの公式ドキュメントでは、「Type」としています。ただし単語にする場合は、大文字から始めるのが一般的です。場合によっては型指定の内容を表す単語として、ProcessやResultなどとすることもあります。
前節で見てきたように、ジェネリクスは利用する段階で型が決定される仕組みのため、クラス定義の段階では、どのような型か分からない状態です。そこで、仮の型記述とするのが、この「< >」内の「T」です。このTはあくまで仮であり、TypePrinterがnewされる際に指定されたデータ型へと変化します。例えば(4)ではnumberを指定しているので、Tがnumberへと変化し、TypePrinterはリスト7のクラスと同等の構造になります。
class TypePrinter { private _param: number; constructor(param: number) { this._param = param } showType(): void { : } }
このように考えると、リスト6の(4)でnewする際のコンストラクタの引数として、数値を渡さなければならないことも理解できると思います。
ジェネリクス型パラメーターとしてstringを指定した場合も同様で、フィールドのデータ型とコンストラクタの引数のデータ型がともに文字列型へと変化します。そのため(5)のように、newの際に文字列を渡す必要があります。
これをリスト8のように、それぞれ違うデータ型を渡すと図6のエラーとなります。
const printerNumNG = new TypePrinter<number>("こんにちは"); const printerStrNG = new TypePrinter<string>(35);
ジェネリクス型パラメーターが具体的に変化する様子が理解できていれば、このエラーもうなずけますね。
このように、ジェネリクスを利用したクラスを定義する場合は、ジェネリクス型パラメーターが実際のデータ型へと変化することを、常に念頭に置く必要があります。それを忘れて、TypePrinter内にリスト9のようなコードを記述した場合には図7のエラーとなりますが、そのエラーの意味が理解できなくなります。
class TypePrinter<T> { : showType(): void { const ans = this._param * 3; : } }
掛け算できる変数は、number型です。一方、フィールド_paramのデータ型はジェネリクス型であり、number型ではありません。そもそも、どのような型になるか未定です。stringになる可能性もありますし、オブジェクトになる可能性もあります。そのようなデータに対しての掛け算処理は、バグです。そのようなバグを防ぐために、あらかじめエラーとなるようにしているのです。
なお、この節で例に挙げたジェネリクスを利用した定義はクラスでしたが、関数にもジェネリクスは利用できます。例えば、リスト10のようなコードです。
function showType<T>(param: T): void { // (1) const paramType = typeof param; console.log(`フィールドのデータ型: ${paramType}`); } showType<number>(35); // (2) showType<string>("こんにちは"); // (2)
(1)のように関数名に続いて「< >」を記述し、その中にジェネリクス型パラメーターを記述します。あとはクラス定義と同様に、関数内ではこのジェネリクス型パラメーターを型記述として利用できます。実際に(1)では、引数paramのデータ型として指定しています。
もちろん、この関数を利用する際もクラスの利用と同様に、(2)のようにジェネリクス型を指定します。
前節で、ジェネリクスの型指定を記述し忘れた場合、自動的にunknown型として扱われることを紹介しました。これを防ぐ方法として、ジェネリクス型パラメーターのデフォルト値を指定することもできます。例えば、TypePrinterのジェネリクス型パラメーターのデフォルト値をnumberとしたい場合は、リスト11のようなクラス宣言を記述します。
class TypePrinter<T = number> { : }
第3回で紹介した、引数のデフォルト値と同じような書式ですね。
前項まで紹介したジェネリクス型パラメーターでは、利用時にどのような型でも指定できてしまいます。これに制約を付けて、指定できる型の範囲を狭めることができます。その際に登場するのが、第6回で紹介したクラスの継承、つまりextendsです。具体例としては、リスト12のようなコードです。なお、リスト12中に登場するCalcBaseやCalcAddは、第6回のリスト1とリスト2のクラスを表します。
class UseCalc<Calc extends CalcBase> { // (1) showBaseNum(calc: Calc) { console.log(`基準となる数値: ${calc.baseNum}`); // (2) } } const useAdd = new UseCalc<CalcAdd>(); // (3) const add = new CalcAdd(45); // (4) useAdd.showBaseNum(add); // (4)
(1)の「< >」内の、「Calc extends CalcBase」という記述が該当します。この記述は、
ジェネリクス型パラメーターCalcは、CalcBaseクラスか、そのクラスを継承したクラス以外は指定できません
という意味です。そのため、(3)ではCalcBaseの子クラスであるCalcAddをジェネリクスの型指定としています。その型指定に合わせて、(4)でそのCalcAddをnewしたものをメソッドshowBaseNum()に渡しています。このコードはもちろん正常に動作します。
このジェネリクスの型指定として、リスト13のようにCalcBaseと継承関係にないクラスを指定すると、図8のエラーとなります。
const useDate = new UseCalc<Date>();
リスト13では、JavaScriptの組み込みオブジェクトであるDateを、ジェネリクスの型として指定しています。これはCalcBaseと継承関係がないため、当然エラーとなります。
この継承関係を前提としたジェネリクス型パラメーターを利用することで、リスト12の(2)のように親クラスに必ず存在するメンバを安心して利用することができます。
TypeScriptのTypeに注目し、あれこれ紹介する本連載の第8回はいかがでしたか?
今回は、クラスや関数を利用する段階で型の指定ができるジェネリクスについて、あれこれ紹介しました。次回は、複数の型をまとめてさらに1つの型にできる「型エイリアス」について紹介します。
WINGSプロジェクト
有限会社 WINGSプロジェクトが運営する、テクニカル執筆コミュニティー(代表山田祥寛)。主にWeb開発分野の書籍/記事執筆、翻訳、講演等を幅広く手掛ける。2021年10月時点での登録メンバーは55人で、現在も執筆メンバーを募集中。興味のある方は、どしどし応募頂きたい。著書、記事多数。
・サーバーサイド技術の学び舎 - WINGS(https://wings.msn.to/)
・RSS(https://wings.msn.to/contents/rss.php)
・Twitter: @yyamada(https://twitter.com/yyamada)
・Facebook(https://www.facebook.com/WINGSProject)
Copyright © ITmedia, Inc. All Rights Reserved.