クラスの型にまつわるあれこれ(1)〜クラス構文そのものについてのあれこれ〜:TypeScriptのTypeあれこれシリーズ(5)
altJS、すなわちJavaScriptの代わりとなる言語の筆頭である「TypeScript」。TypeScriptという言語名が示す通り、JavaScriptに「Type」、つまり型の概念を持ち込んだものです。本連載では、このTypeScriptの型に関して、さまざまな方向から紹介していきます。前回は関数の型に関して、型ガードやオーバーロードなどを紹介しました。今回から3回にわたって、オブジェクトのひな型となるクラスに関して、あれこれ紹介していきます。今回は、そのうちクラス構文そのものについてのあれこれを紹介していきます。
クラスの基本形
クラスはECMAScript 2015(ES2015)でJavaScriptにも導入され、現在ではJavaScriptでも標準で利用できます。ただし、TypeScriptならではのクラス構文もあります。その辺りも含めて、基本形を紹介していきます。
クラスはオブジェクトのひな型
クラスというのは、オブジェクトのひな型です。例えば、Scoreというクラスがあったとします。このクラスをリスト1の(1)のようにnewすることで、Scoreに含まれたメンバー(構成要素)がそっくりそのまま含まれたオブジェクトが生成されます。
const taro = new Score(); // (1) taro.name = "田中太郎"; // (2) taro.point = 98; // (3) taro.show("すばらしい!"); // (4)
第1回で紹介したように、この変数taroはオブジェクト型のScore型です。そしてScoreに定義されたメンバー、すなわちプロパティとメソッドが含まれていることになり、利用できるようになります。例えばScoreには、名前を表すnameプロパティと、点数を表すpointプロパティがあり、それらを表示するshow()メソッドがあるとすると、(2)と(3)でそれぞれのプロパティにデータを格納して、(4)で表示メソッドを実行しています。
クラスのメンバー
このScoreクラスにメンバーを自分で定義する場合、TypeScriptではリスト2のようなコードを記述します。
class Score { name = ""; // (1) point = 0; // (2) show(msg: string):void { // (3) console.log(`${this.name}さんは${this.point}点です。`); } }
(3)がメソッドです。こちらの書式は、原則JavaScriptと変わりません。違うのは、引数と戻り値の型を記述する点です。これは、いわばfunctionキーワードを記述しないTypeScriptの関数ともいえます。もちろん、引数の省略やデフォルト値も利用できます。
一方、大きく違うのがプロパティの定義です。JavaScriptでは、コンストラクタを定義し、リスト3のようなコードを記述することでプロパティを設定していました*。
* ES2022では、JavaScriptでもフィールドでのプロパティ定義が可能となりました。
class Score { constructor() { this.name = ""; this.point = 0; } : }
もちろん、TypeScriptもこの方法でプロパティを定義できますが、もっと可読性の高い方法として、リスト2の(1)や(2)のようにクラスブロック直下に変数として定義できます。これをフィールドといいます。クラスをnewしてオブジェクトを生成する際に、このフィールドを基にプロパティが生成されます。
【補足】プロパティとフィールド
JavaScriptは歴史的にクラス構文がありませんでした。全てがオブジェクトであり、そのオブジェクトの中の構成要素をプロパティと呼んでいました。このプロパティにはデータ(変数)だけではなく、処理(関数)も格納できます。このうち、処理を格納したものをメソッド、データを格納したものを狭義のプロパティと呼んでいました。その後、ES2015でクラス構文が導入されても、その実はオブジェクトのままです。クラス内のデータ部分をきっちり定義する構文がありませんでした。上記のようにコンストラクタ経由で代入することで、データが追加され狭義のプロパティが生成されます。
一方、Javaなど最初からクラス構文がある言語では、クラス内でデータ部分をきっちり定義します。これをフィールドと呼んでいます。TypeScriptとES2022ではこの考え方を取り入れ、クラス内に定義したデータ部分をフィールドと呼んでいます。
フィールドの型と初期値
フィールドは変数の一種ですので、データ型があります。リスト2の(1)も(2)も、データ型を記述するならばリスト4のようになります。
class Score { name: string = ""; point: number = 0; : }
とはいえ型推論が働くので、データ型を記述しなくても、nameはstring型、pointはnumber型として扱われます。
これはもちろん初期値からの型推論ですので、初期値を記述しないフィールドの場合は、any型として扱われてしまいます。第1回で紹介したように、any型は危険です。そのため、初期値を記述しないフィールドの場合は、リスト5のようにデータ型を記述しておく必要があります。
class Score { note: string; : }
さらにいえば、初期値を記述しないフィールドはundefinedとなってしまい、これはこれで問題を引き起こす可能性が多々あります。それを避けるために、フィールドには初期値を記述しておく方が安全といえます。
TypeScriptには、このフィールドの初期値の記述忘れをチェックする機能があり、それが、コンパイルオプションのstrictPropertyInitializationです。ただし、このオプションは第1回で紹介したstrictNullChecksオプションと一緒に指定する必要があります。この両方を指定してコンパイルを行うと、図1のようにコンパイルエラーとして初期値のないフィールドを教えてくれます。
もし、クラスの定義段階でどうしても初期値が決まらないフィールドがある場合は、リスト6のように「!」を記述します。
class Score { note!: string; : }
この場合は、strictPropertyInitializationオプションを指定してコンパイルを行っても、エラーとなりません。
そのため、通常はこのオプションを指定した状態でコンパイルを行い、その上で「!」を利用した方がよいでしょう。
TypeScriptらしいクラス定義
前節で紹介したクラスの基本形は、フィールドやデータ型の記述だけでも十分TypeScriptらしい記述です。ここからさらにJavaScriptにはない、TypeScriptらしいクラス構文を紹介していきます。
readonlyプロパティ
TypeScriptのオブジェクトでは、読み取り専用のプロパティを作成することができます。その場合は、リスト7の(1)のようにフィールドにreadonlyを記述します。
class Score { readonly id = 4456; // (1) : show(msg: string):void { this.id = 5548; // (2) : } } const taro = new Score(); taro.id = 5548; // (3) console.log(`taroのidは${taro.id}`); // (4)
このようなフィールドは、クラス内メソッドからでも変更できません。もしリスト7の(2)のように変更しようとすると、図2のようにエラーとなります。
同様に、このクラスをnewしたオブジェクトのreadonlyフィールド(プロパティ)に対して、リスト7の(3)のように値を代入しようとすると、図3のようにエラーとなります。
もっとも、これはあくまで値の代入であって、リスト7の(4)のように値を利用する場合は全く問題ありません。
readonlyフィールドに値を代入できるコンストラクタ
では、このreadonlyフィールドは、リスト7の(1)のようにハードコーディングされた初期値以外は利用できないのかというと、そうではありません。このreadonlyフィールドに唯一値が代入できるメソッドがあります。それがコンストラクタです。例えば、リスト8のようなコードは問題なく動作します。
class Score { readonly id: number; // (1) : constructor(id: number) { // (3) this.id = id; // (4) } } const taro = new Score(4456); // (5)
JavaScriptのクラス構文でもおなじみのように、コンストラクタには引数を設定できます。それを利用して、idを受け取れるようにしているのが(3)です。その受け取った引数のidを、フィールドのidに格納しているのが(4)です。そのフィールドのidは(1)のようにreadonlyとしていますが、問題なく値が代入できます。
ただし、その場合は初期値を記述してはダメです。また初期値がないため、型推論が働きません。必ず型を記述するようにします。もし、リスト7の(1)のように初期値を記述してしまったら、たとえコンストラクタでも図4のようにエラーとなるので注意が必要です。
なお、コンストラクタに引数が設定されたクラスをnewする場合は、(5)のように引数を渡す必要があります。こちらはJavaScriptでもおなじみですね。
【補足】コンストラクタの戻り値
コンストラクタは、クラスがnewされるとき、すなわちオブジェクトが生成されるときに実行される特殊なメソッドです。そのため戻り値はあり得ず、戻り値の型も記述しません。
コンストラクタのオーバーロード
TypeScriptのクラスのコンストラクタは、オーバーロードが可能です。構文としては、前回紹介した関数のオーバーロードと同様です。例えば、Scoreクラスにリスト9の(1)〜(3)のようなコンストラクタを設定したとします。
class Score { : constructor(id: number); // (1) constructor(id: number, name: string, point: number); // (2) constructor(id: number, name?: string, point?: number) { // (3) this.id = id; : } : } const taro = new Score(4456); // (4) : const jiro = new Score(5532, "鈴木二郎", 56); // (5)
(1)と(2)がオーバーロードシグネチャです。(1)は引数がidのみの場合であり、(2)がフィールドの全ての値を引数として受け取る場合です。一方、(3)が実装シグネチャです。関数のオーバーロードと同様に、オーバーロードシグネチャの全ての引数に対応するシグネチャになっていることが分かります。
このようにコンストラクタがオーバーロードすることで、このScoreクラスをnewする場合、リスト9の(4)のように引数が1個の場合と、(5)のように引数が3個の場合のどちらを利用するかを選べるようになります。
メソッドのオーバーロード
コンストラクタ同様に、メソッドもオーバーロード可能です。例えば、show()メソッドを引数がある場合とない場合の両方に対応させたい場合、リスト10のような定義コードになります。
class Score { : show(): void; show(msg: string):void; show(msg?: string):void { : } : }
【補足】引数の省略構文でも定義可能
オーバーロードの例として掲載したリスト9やリスト10は、実は、オーバーロードを利用しなくても、引数の省略構文や初期値構文でも記述できます。ただし、前回紹介したように、オーバーロードでないと対応できないパターンというのもあり得ます。ここでは、オーバーロード構文が使えることを紹介するための例と捉えてください。
アクセサーとアクセス修飾子
JavaScript同様に、TypeScriptのクラスではアクセサーを利用できます。
アクセサーとは
アクセサーとはゲッターとセッターを合わせた呼び方で、フィールドの値をやりとりするための特殊なメソッドです。例えば、Scoreクラスにアクセサーを追加すると、リスト11のようなコードになります。
class Score { _name = ""; // (1) _point = 0; // (2) : get nameWithSan(): string { // (3) return `${this._name}さん`; } set point(value: number) { // (4) if(value < 0) { value = 0; } this._point = value; } get point(): number { // (5) return this._point; } } const taro = new Score(4456, "田中太郎", 98); console.log(taro.nameWithSan); // (6) taro.point = -20; // (7) console.log(taro.point);
(3)が、フィールドのnameに「さん」を付けたものをリターンするゲッターであるnameWithSanです。JavaScriptのゲッター構文との違いは、戻り値の型記述があるぐらいです。このようなゲッターを用意しておくと、(6)のようにメソッドではなく、プロパティとしてアクセスできるようになります。
同様に、(4)がセッターです。こちらは、値として受け取ったポイント数が負の場合は、強制的に0にリセットする処理が含まれています。そのため、(7)のように負数を代入しようとすると、フィールドのポイント数は0になります。
そのポイント数のゲッターが(5)です。こちらは、単にフィールドの値をリターンしているだけです。
なお、セッターはその働き上、そもそも何かの値をリターンすることはありません。つまり、戻り値の型はvoid型と決まっています。そのため、この型記述は不要です。(4)にvoidの記述がないのはそのためです。
【補足】targetオプションの指定
アクセサーを含むTypeScriptコードをコンパイルする場合は、targetオプションとして、ES5以上のESバージョンを指定する必要があります。
フィールドとアクセサープロパティは別名にする
ここで、リスト11の(1)と(2)について補足しておきます。アクセサーで定義されたプロパティ名は、フィールドで定義されたプロパティ名と同名ではエラーとなります。図5は、(2)のフィールド名をpointとした場合のエラーです。
これを避けるためによく行う手法は、フィールドに_(アンダースコア)を接頭辞として付与する方法です。リスト11の(1)と(2)では、そのために_name、_pointという記述になっています。
この仕組みのおかげで、実体のあるフィールドのプロパティとアクセサープロパティを、外部からは区別せずに利用できるようになっています。
ゲッターのみはreadonly
ここで、nameWithSanプロパティに注目します。このプロパティは、ゲッターのみで提供されているプロパティですので、フィールドがあるわけではありません。さらには、セッターもありません。このようにゲッターのみのプロパティは、自動的にreadonlyとなるようになっています。図6は、試しにこのnameWithSanにデータを代入しようとしてエラーとなった画面です。エラー文面から、readonlyなのが分かります。
この理由は明確です。実体のあるフィールドのプロパティは、値を受け取ると即座にフィールドにその値が格納されます。またpointのように、セッターが存在するプロパティの場合は、値を受け取るとそのセッター内のコードが実行されます。
一方、ゲッターのみのプロパティでは、この値を受け取る仕組みが存在しないことになります。この問題を回避するために、TypeScriptでは自動的にreadonlyとなるようになっています。
アクセス修飾子
このように、フィールドとアクセサーを駆使することで、さまざまなデータのやりとりを実現できます。ただし、現状では問題があります。リスト11の(4)でせっかく負数が代入できないようにしたポイント数も、リスト12のようなコードを記述すると、直接フィールドプロパティに負数を代入できてしまいます。
taro._point = -20;
これではアクセサーでデータの入出力を制御しても、意味がなくなってしまいます。この問題を解決する方法がTypeScriptにはあり、リスト13の(1)や(2)のように、フィールドにprivateキーワードを記述します。
class Score { private _name = ""; // (1) private _point = 0; // (2) : get point(): number { return this._point; // (3) } } taro._point = -20; // (4)
こうすることで、このフィールドの値はクラス内からのみアクセスできるようになります。例えば、(3)のようにクラス内のメソッドやアクセサーからアクセスして、その値を利用することは問題なく行える一方で、(4)のようにクラス外部からは利用できなくなります。実際、(4)のコードでは図7のエラーとなります。
このように、クラスのメンバーへのアクセスを制限するためのキーワードをアクセス修飾子といい、TypeScriptでは表1の3個あります。これらのアクセス修飾子は、フィールドだけでなくメソッドにも利用できます。
修飾子 | 内容 |
---|---|
public | クラス内外を問わずどこからでもアクセスできるメンバー |
private | クラス内からのみアクセスできるメンバー |
protected | クラス内と子クラスからのみアクセスできるメンバー |
表1 アクセス修飾子 |
なお、リスト12までのようにアクセス修飾子を記述しない場合は、全てpublicとして扱われます。そのため、通常publicは記述しません。また、protectedで登場する子クラスというのは、クラスの継承という仕組みに基づいています。継承は、次回紹介します。
【補足】ES2022のprivateフィールド
先の補足の通り、ES2022ではフィールドが導入されました。それと同時に、プライベートメンバーもサポートされるようになり、その場合は次のように#を記述します。
#_name = "";
まとめ
TypeScriptのTypeに注目し、あれこれ紹介する本連載の第5回はいかがでしたか? 今回は、TypeScriptでのクラス構文について紹介しました。
次回はこの続きとして、クラスの継承やクラスをnewしたオブジェクトの型同士の関係を紹介します。
筆者紹介
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.