TypeScript は、まだ ECMAScript に追加されていない提案段階の機能を積極的に先取りしている。
それはクラスも例外ではなく、例えば先日リリースされたv3.8
では、現時点ではまだ提案段階であるプライベートフィールドが使えるようになった。
この記事では、TypeScript でのクラスの書き方の基本や、アクセス修飾子などの TypeScript 独自の機能、そしてプライベートフィールドのような最新の機能について、説明していく。
動作確認はv3.8.2
で行った。
TypeScript ではインスタンスメンバの宣言が必須
以下は JavaScript と同じような感覚で書いたコードだが、これだとエラーになってしまう。
class MyClass { constructor(x: number) { this.x = x; // Property 'x' does not exist on type 'MyClass'. } }
x
なんてプロパティは存在しない、と言われてしまう。
以下のように、予め宣言しておく必要がある。
class MyClass { x: number; constructor(x: number) { this.x = x; } }
宣言時に値を渡すこともできる。
ECMAScript ではこの書き方はまだできず、使いたい場合は Babel のプラグイン等で対応する必要があるが、TypeScript では既に利用できる。
class MyClass { x: number; y = 2; constructor(x: number) { this.x = x; } } const instance = new MyClass(1); console.log(instance.x); // 1 console.log(instance.y); // 2
型推論が行われるので、y
の型はnumber
になる。
静的メンバと継承
静的メンバや継承については、TypeScript 特有のルール等はない。JavaScript と同じように書ける。
static
キーワードをつけたメンバは、インスタンスではなく、そのクラス自身のメンバになる。
class Product { constructor() { Product.count += 1; } static count = 0; static getProductCount = () => Product.count; } const foo = new Product(); const bar = new Product(); console.log(Product.getProductCount()); // 2 console.log(Object.getOwnPropertyNames(foo)); // [] console.log(Object.getOwnPropertyNames(Product)); // [ 'length', 'prototype', 'name', 'count', 'getProductCount' ]
継承も、特に変わったところはない。
プロトタイプチェーンを辿って、親クラスのプロトタイプメソッドを利用できる。
class Parent { showMessage() { console.log('This is super class method.'); } } class Child extends Parent {} const instance = new Child(); instance.showMessage(); // This is super class method. console.log(Object.getOwnPropertyNames(Parent.prototype)); // [ 'constructor', 'showMessage' ] console.log(Object.getOwnPropertyNames(Child.prototype)); // [ 'constructor' ]
これらの機能については、過去にブログに書いた。
クラスの型情報
クラスを定義すると、その名前の型が自動的に作られる。
// MyClass というクラスを定義したのと同時に、MyClass という型も定義される class MyClass { x = 1; }
注意しなければならないのは、上記のMyClass
型が指しているのはMyClass
のインスタンスの型であり、MyClass
そのものではないということ。
class MyClass { x = 1; } const foo: MyClass = new MyClass(); // OK const bar: MyClass = MyClass; // Error
型の前にtypeof
キーワードをつけることで、クラス自身を表現するようになる。
class MyClass { x = 1; } const foo: MyClass = new MyClass(); // OK const bar: typeof MyClass = MyClass; // OK
TypeScript は構造的部分型を採用しており、それはクラスでも変わらない。そのため、型に互換性があると判断されれば、実際にそのクラスやインスタンスである必要はない。
下記のMyClass
はインスタンスメンバとしてx
を、そして静的メンバとして、つまりクラス自身のメンバとして、y
を定義している。
どちらも型推論の結果、number
型となる。
そのため、number
型のx
というプロパティを持ったオブジェクトなら、MyClass
のインスタンスと同じ構造になり、MyClass
型と互換性があると判断される。
class MyClass { x = 1; static y = 2; } const foo: MyClass = {x: 9}; // OK
kyeof
を使って型のプロパティ名を抽出してみると分かりやすい。
class MyClass { x = 1; static y = 2; } type Foo = keyof MyClass; // type Foo = "x" type Bar = keyof typeof MyClass; // type Bar = "prototype" | "y"
abstract
abstract
キーワードは TypeScript 独自の機能で、抽象クラスや抽象メンバを表現できる。
抽象クラスについての説明や、abstract
の実践的な使い方などについては、別途記事を書いた。
ここでは、abstract
キーワードをつけたクラスやメンバがどのような挙動をするのかを、見ていく。
abstract
をつけたクラスは抽象クラスとなり、インスタンスを作成しようとするとエラーになる。
abstract class Parent {} class Child extends Parent {} const foo = new Parent(); // Cannot create an instance of an abstract class. const bar = new Child(); // OK
抽象クラス内のメンバにはabstract
をつけることが可能で、そのメンバは抽象メンバとなる。
抽象メンバは子クラスで実装する必要があり、実装していない子クラスはエラーになる。
abstract class Parent { abstract x: number; abstract y: () => string; } // Non-abstract class 'A' does not implement inherited abstract member 'x' from class 'Parent'. // Non-abstract class 'A' does not implement inherited abstract member 'y' from class 'Parent'. class A extends Parent {} // OK class B extends Parent { x = 1; y = () => 'a'; }
?
をつけても、それはあくまでもundefined
が許されるというだけであり、実装は必要。
abstract class Parent { abstract x: number; abstract y: () => string; abstract z?: () => boolean; // (property) Parent.z?: (() => boolean) | undefined } // Non-abstract class 'A' does not implement inherited abstract member 'z' from class 'Parent'. class A extends Parent { x = 1; y = () => 'a'; } // OK class B extends Parent { x = 1; y = () => 'a'; z = undefined; }
子クラスが実装していれば、孫クラスは抽象メンバを実装しなくても問題ない。
abstract class Parent { abstract x: number; } // OK class Child extends Parent { x = 1; } // OK class GrandChild extends Child {}
もちろん実装してもよいが、その場合は型が一致している必要がある。
abstract class Parent { abstract x: number; } // OK class Child extends Parent { x = 1; } // Property 'x' in type 'GrandChild' is not assignable to the same property in base type 'Child'. // Type 'string' is not assignable to type 'number'. class GrandChild extends Child { x = 'a'; }
孫クラスが抽象メンバを実装しているが子クラスは実装していない、というケースでは、子クラスのみがエラーになる。
abstract class Parent { abstract x: number; } // Non-abstract class 'Child' does not implement inherited abstract member 'x' from class 'Parent'. class Child extends Parent {} // OK class GrandChild extends Child { x = 1; }
アクセス修飾子
アクセス修飾子という機能を使うと、メンバのアクセス可能範囲を制御することができる。
private
はそのクラス内でのみアクセス可能、protected
はそのクラスと子クラスからのみアクセス可能、public
はどこからでもアクセス可能、となる。
アクセス修飾子をつけていない場合は、public
になる。
class Parent { private foo: number; protected bar: number; public baz: number; qux: number; constructor(foo: number, bar: number, baz: number, qux: number) { this.foo = foo; this.bar = bar; this.baz = baz; this.qux = qux; } } class Child extends Parent { method() { console.log(super.foo); // Error console.log(super.bar); // OK console.log(super.baz); // OK console.log(super.qux); // OK } } const instance = new Parent(1, 2, 3, 4); console.log(instance.foo); // Error console.log(instance.bar); // Error console.log(instance.baz); // OK console.log(instance.qux); // OK
new
でインスタンスを作成する際、constructor
メソッドが実行される。
そのため、constructor
をprotected
にすると、外部からconstructor
にアクセスできなくなるため、外部でインスタンスを作成することは不可能になる。
protected
なので子クラスからはアクセスできるため、子クラスのconstructor
でsuper
を使って実行することはできる。
class Parent { x: number; protected constructor(x: number) { this.x = x; } } class Child extends Parent { constructor(x: number) { super(x); } } const foo = new Child(1); console.log(foo.x); // 1 const bar = new Parent(1); // Constructor of class 'Parent' is protected and only accessible within the class declaration.
アクセス修飾子をconstructor
の仮引数で使うと、メンバの宣言を省略できる。
class MyClass { constructor(public x: number) { this.x = x; } } const instance = new MyClass(1); console.log(instance.x); // 1
さらに省略することもできる。
class MyClass { constructor(public x: number) {} } const instance = new MyClass(1); console.log(instance.x); // 1
readonly
はその名の通り、メンバを読み取り専用にするための修飾子。
class MyClass { x: number; readonly y: number; constructor(x: number, y: number) { this.x = x; this.y = y; } } const instance = new MyClass(1, 2); console.log(instance.x, instance.y); // 1 2 instance.x = 8; // OK instance.y = 9; // Error
プライベートフィールド
プライベートフィールドは ECMAScript のプロポーザルで、今日時点での Stage は 3。
TypeScript はv3.8
でこの機能を取り入れたので、既に使うことができる。
接頭辞として#
をつけたメンバが、プライベートフィールドとして扱われる。
プライベートフィールドは、宣言したクラスのなかでのみ利用できる。
class MyClass { #x: number; constructor(x: number) { this.#x = x; } getX () { return this.#x; } } const instance = new MyClass(1); console.log(instance.getX()); // 1 console.log(instance.#x); // Property '#x' is not accessible outside class 'MyClass' because it has a private identifier.
プライベートフィールドとクラスフィールドはそれぞれ別のメンバなので、名前が重複しても問題ない。
class MyClass { #x = 1; x = 2; getPrivateX () { return this.#x; } getPublicX () { return this.x; } } const instance = new MyClass(); console.log(instance.getPrivateX()); // 1 console.log(instance.getPublicX()); // 2 console.log(instance.x); // 2
プライベートフィールドと private 修飾子の違い
プライベートフィールドは、既に紹介したprivate
修飾子と似ている。
どちらも、宣言したクラスの外からは利用できず、子クラスから利用することもできない。
だが差異もいくつかある。ここからは、両者の違いを見ていく。
Soft Private or Hard Private
Soft Private
とHard Private
という概念がある。
以下の記事が分かりやすいが、端的に言えば、後者のほうがより厳密にアクセスが制限されている。
Private Class Field の導入に伴う JS の構文拡張 | blog.jxck.io
private
修飾子はSoft Private
であるため、外部から存在を確認することができるし、外部からアクセスするための抜け道もある。
だがプライベートフィールドはHard Private
であり、外部から存在を確認することはできず、抜け道もない。
class MyClass { private soft = 1; #hard = 2; } const instance = new MyClass(); console.log(Object.getOwnPropertyNames(instance)); // [ 'soft' ] console.log(instance['soft']); // 1 // Element implicitly has an 'any' type because expression of type '"#hard"' can't be used to index type 'MyClass'. // Property '#hard' does not exist on type 'MyClass'. console.log(instance['#hard']);
private
修飾子を使ったsoft
はgetOwnPropertyNames
の一覧に出てくるし、ブラケット記法でアクセスすることもできてしまう。
だがプライベートフィールドを使ったhard
は、外部からのアクセスが完全に遮断されている。
エラーを無視してトランスパイルした場合の挙動
private
などのアクセス修飾子は、TypeScript 独自の機能である。トランスパイルする際に型情報などと同じように除去されるため、生成される JavaScript のコードには影響を及ぼさない。
そのため、エラーを無視してトランスパイルした場合、作られた JavaScript のコードは問題なく動く。
以下は外部からx
にアクセスしようとしているためエラーになるが、@ts-ignore
でそのエラーを無視している。
このコードをトランスパイルした JavaScript ファイルを実行すると、エラーは出ず、1
と表示される。
class MyClass { private x = 1; } const instance = new MyClass(); // @ts-ignore console.log(instance.x);
だがプライベートフィールドは、ECMAScript のプロポーザルを先行して取り入れたものであり、TypeScript 独自の機能ではない。
その挙動はプロポーザルの内容に準拠している必要があり、TypeScript 上でのエラーさえ回避すればよい、というものではない。
そのため、エラーを無視してトランスパイルしても、壊れた JavaScript ファイルが生成されてしまう。
先程と同じように@ts-ignore
でエラーを無視して以下のコードをトランスパイルしてみる。
class MyClass { #x = 1; } const instance = new MyClass(); // @ts-ignore console.log(instance.#x);
TypeScript はエラーを出さないのでトランスパイルは行われるが、生成されたファイルは壊れており、実行するとSyntaxError
になる。
有効な target
プライベートフィールドは、tsconfig.json
のtarget
がES2015
以上でないと利用できない。
例えばES5
にしてみると、以下のコードはエラーになる。
class MyClass { #x = 1; // Private identifiers are only available when targeting ECMAScript 2015 and higher. }
private
修飾子の場合はtarget
の制限はなく、例えばES3
にしても以下のコードは動く。
class MyClass { private x = 1; // OK }