TypeScriptのジェネリクスにまつわるあれこれTypeScriptのTypeあれこれシリーズ(8)

altJS、すなわち、JavaScriptの代わりとなる言語の筆頭であるTypeScript。そのTypeScriptは、言語名が示す通り、JavaScriptにType、つまり、型の概念を持ち込んだものです。本連載では、このTypeScriptのType(型)に関して、さまざまな方向から紹介していきます。前回は、クラスの型に関するあれこれを紹介する最後として、クラス内での特殊なthisの使い方、抽象クラス、staticを紹介しました。今回は、クラスや関数を利用する段階で型の指定ができるジェネリクスについて、あれこれ紹介していきます。

» 2022年05月13日 05時00分 公開

この記事は会員限定です。会員登録(無料)すると全てご覧いただけます。

「TypeScriptのTypeあれこれシリーズ」のインデックス

連載:TypeScriptのTypeあれこれシリーズ

ジェネリクスの型指定

 本連載ではジェネリクス(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)
リスト1

 このnumberSetには、リスト1の(2)や(3)のように数値データは格納できますが、(4)の文字列データは格納できず、図1のエラーとなります。

図1 ジェネリクスの型指定と違う型データの格納でエラーとなった画面

 これが逆に、リスト2のようなジェネリクスの型指定でSetオブジェクトをnewした場合、(2)と(3)はエラーとなり、(4)はエラーとなりません(図2)。

const numberSet = new Set<string>();
リスト2
図2 図1とは逆にnumber型データの格納でエラーとなった画面

 このように利用する段階で型を指定でき、型に関してエラーの起きない(型安全な)コーディングのできる仕組みが、ジェネリクスです。

ジェネリクスを利用しない場合

 もちろん、リスト3のようにジェネリクスの型を指定せずにSetオブジェクトを利用することもできます。この場合は図3のように、数値も文字列も格納できてしまいます。

const numberSet = new Set();
リスト3
図3 ジェネリクスの型を指定しなかった場合の画面

 このように、ジェネリクスの型を指定しない方がエラーとならず便利なように思えますが、逆に危険です。というのは、配列にしてもSetにしても、同種のデータをまとめておくためのものです。これらのオブジェクトに、数値と文字列のように異種のデータを混在させると、その存在意義に反するだけでなく、バグの原因となります。

 そこでTypeScriptには、そのための安全網が設けられています。図3にあるように、ジェネリクスの型を指定しなかった場合、自動的にunknown型として扱われます。第1回で紹介したように、unknown型の場合は、その変数のデータをまともに操作できません。例えば、ジェネリクスの型を指定しなかったリスト3のnumberSetを、リスト4のようにループ処理した場合、図4のエラーとなります。

for (const element of numberSet) {
	element * 2;
}
リスト4
図4 ジェネリクスの型を指定しなかったnumberSetのループコードでエラー

 これは、各要素を表す変数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)
リスト5

 この場合、1つ目のstringがキーを表し、2つ目のnumberが値を表します。そのため(2)のように、set()メソッドでデータを登録する際は、第1引数は文字列を、第2引数は数値を渡す必要があります。(3)や(4)のように、どちらか片方だけでも違うデータ型の値を渡すと、図5のようにエラーとなります。

図5 複数のジェネリクスの型指定では1つでも型が違うとエラーとなる

ジェネリクスを組み込む方法

 前項で複数ジェネリクスの例として挙げた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();
リスト6

 このコードの実行結果は、以下のようになります。

フィールドのデータ型: 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 {
		:
	}
}
リスト7

 このように考えると、リスト6の(4)でnewする際のコンストラクタの引数として、数値を渡さなければならないことも理解できると思います。

 ジェネリクス型パラメーターとしてstringを指定した場合も同様で、フィールドのデータ型とコンストラクタの引数のデータ型がともに文字列型へと変化します。そのため(5)のように、newの際に文字列を渡す必要があります。

 これをリスト8のように、それぞれ違うデータ型を渡すと図6のエラーとなります。

const printerNumNG = new TypePrinter<number>("こんにちは");
const printerStrNG = new TypePrinter<string>(35);
リスト8
図6 ジェネリクス型指定と違うコンストラクタ引数でエラーとなった画面

 ジェネリクス型パラメーターが具体的に変化する様子が理解できていれば、このエラーもうなずけますね。

ジェネリクス利用の注意点

 このように、ジェネリクスを利用したクラスを定義する場合は、ジェネリクス型パラメーターが実際のデータ型へと変化することを、常に念頭に置く必要があります。それを忘れて、TypePrinter内にリスト9のようなコードを記述した場合には図7のエラーとなりますが、そのエラーの意味が理解できなくなります。

class TypePrinter<T> {
	:
	showType(): void {
		const ans = this._param * 3;
		:
	}
}
リスト9
図7 ジェネリクス型フィールドに掛け算してエラーとなった画面

 掛け算できる変数は、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)
リスト10

 (1)のように関数名に続いて「< >」を記述し、その中にジェネリクス型パラメーターを記述します。あとはクラス定義と同様に、関数内ではこのジェネリクス型パラメーターを型記述として利用できます。実際に(1)では、引数paramのデータ型として指定しています。

 もちろん、この関数を利用する際もクラスの利用と同様に、(2)のようにジェネリクス型を指定します。

ジェネリクス型パラメーターのデフォルト値

 前節で、ジェネリクスの型指定を記述し忘れた場合、自動的にunknown型として扱われることを紹介しました。これを防ぐ方法として、ジェネリクス型パラメーターのデフォルト値を指定することもできます。例えば、TypePrinterのジェネリクス型パラメーターのデフォルト値をnumberとしたい場合は、リスト11のようなクラス宣言を記述します。

class TypePrinter<T = number> {
	:
}
リスト11

 第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)
リスト12

 (1)の「< >」内の、「Calc extends CalcBase」という記述が該当します。この記述は、

ジェネリクス型パラメーターCalcは、CalcBaseクラスか、そのクラスを継承したクラス以外は指定できません


という意味です。そのため、(3)ではCalcBaseの子クラスであるCalcAddをジェネリクスの型指定としています。その型指定に合わせて、(4)でそのCalcAddをnewしたものをメソッドshowBaseNum()に渡しています。このコードはもちろん正常に動作します。

 このジェネリクスの型指定として、リスト13のようにCalcBaseと継承関係にないクラスを指定すると、図8のエラーとなります。

const useDate = new UseCalc<Date>();
リスト13
図8 継承関係のないデータ型をジェネリクス型指定としてエラーとなった画面

 リスト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.

スポンサーからのお知らせPR

注目のテーマ

Microsoft & Windows最前線2025
AI for エンジニアリング
ローコード/ノーコード セントラル by @IT - ITエンジニアがビジネスの中心で活躍する組織へ
Cloud Native Central by @IT - スケーラブルな能力を組織に
システム開発ノウハウ 【発注ナビ】PR
あなたにおすすめの記事PR

RSSについて

アイティメディアIDについて

メールマガジン登録

@ITのメールマガジンは、 もちろん、すべて無料です。ぜひメールマガジンをご購読ください。