30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

TypeScript でクラスを書くための初歩

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' ]

これらの機能については、過去にブログに書いた。

numb86-tech.hatenablog.com

numb86-tech.hatenablog.com

クラスの型情報

クラスを定義すると、その名前の型が自動的に作られる。

// 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の実践的な使い方などについては、別途記事を書いた。

numb86-tech.hatenablog.com

ここでは、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メソッドが実行される。
そのため、constructorprotectedにすると、外部からconstructorにアクセスできなくなるため、外部でインスタンスを作成することは不可能になる。
protectedなので子クラスからはアクセスできるため、子クラスのconstructorsuperを使って実行することはできる。

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 PrivateHard 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修飾子を使ったsoftgetOwnPropertyNamesの一覧に出てくるし、ブラケット記法でアクセスすることもできてしまう。
だがプライベートフィールドを使った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.jsontargetES2015以上でないと利用できない。
例えば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
}

参考資料

never 型を使った TypeScript のテクニック

「発生し得ない値」などのように説明されるnever型。
概念としては分かるのだが、実際にどのようなケースで使えばよいのかイメージできずにいた。
neverを使ったテクニックを調べていて多少のイメージは掴めてきたので、整理しておく。

動作確認は TypeScript のv3.7.5で行っている。

never 型の特性

まずはnever型がどういった型なのか、理解する。

決して発生し得ない値や型は、never型になる。
例えば以下のif文では、elseブロックは絶対に実行されないため、そのなかではfooneverになる。

const foo = true;

if (foo) {
  foo; // const foo: true
} else {
  foo; // const foo: never
}

「存在し得ない値」なので、どんな値も代入することはできない。

const foo: never = 1; // Error
const bar: never = undefined; // Error
const baz: never = null; // Error

そして、存在し得ない型であるという特性上、T | neverは必ずTになる。

type Foo = number | never; // type Foo = number

type Bar<T> = T | never;
type Baz = Bar<number>; // type Baz = number
type Qux = Bar<string>; // type Qux = string

存在し得ない型を Union Types に追加しても何の変化も生まれないため、このような結果になる。

type Foo = 1 | 2; // type Foo = 1 | 2
type Bar = 1 | 2 | 3; // type Bar = 1 | 2 | 3
type Baz = 1 | 2 | 3 | never; // type Baz = 1 | 2 | 3

以上の特性を利用したテクニックを 2 つ挙げる。

Type Guard と組み合わせる

neverと Type Guard を組み合わせることで、組み合わせなかった場合には見逃してしまうエラーを、TypeScript が検知できるようになる。

例としてSignal型とgetMessageFromSignal関数を実装する。
まずはneverを使わずに書いた場合。

type Signal = 'green' | 'red';

const getMessageFromSignal = (signal: Signal): string => {
  switch (signal) {
    case 'green': {
      return 'Go!';
    }
    case 'red': {
      return 'Stop!';
    }
    default: {
      throw new Error(`${signal} is not Signal.`);
    }
  }
};

getMessageFromSignalSignalを渡し、その値に応じて文字列を返す。
引数にSignal型以外の値を渡すと、TypeScript はエラーを吐く。
そのため例えばgreanのようにタイプミスしても、それに気付くことができる。

console.log(getMessageFromSignal('green')); // Go!
console.log(getMessageFromSignal('grean')); // Argument of type '"grean"' is not assignable to parameter of type 'Signal'.

特に問題ないように見えるが、Signalの値を増やしたときにgetMessageFromSignal側の対応を怠ると、TypeScript では検知できないエラーが発生してしまう。

以下ではSignalyellowを追加したが、getMessageFromSignal側ではその対応を行っていない。
そのためgetMessageFromSignalyellowを渡すと、defaultブロックが実行されてエラーが投げられる。

type Signal = 'green' | 'red' | 'yellow';

const getMessageFromSignal = (signal: Signal): string => {
  switch (signal) {
    case 'green': {
      return 'Go!';
    }
    case 'red': {
      return 'Stop!';
    }
    default: {
      throw new Error(`${signal} is not Signal.`);
    }
  }
};

だがyellowSignal型なので、getMessageFromSignalに渡しても TypeScript はエラーを出してくれない。
そのため、このコードを実際に実行したときに初めてエラーに気付くことになる。

// 型チェックを行ってもエラーにならない
console.log(getMessageFromSignal('yellow')); // Error: yellow is not Signal.

never型を使うことで、このエラーを TypeScript が検知してくれるようになる。
具体的には、defaultブロックのなかで明示的にneverを使う。

type Signal = 'green' | 'red' | 'yellow';

const getMessageFromSignal = (signal: Signal): string => {
  switch (signal) {
    case 'green': {
      return 'Go!';
    }
    case 'red': {
      return 'Stop!';
    }
    default: {
      const strangeValue: never = signal; // Type '"yellow"' is not assignable to type 'never'.ts(2322)
      throw new Error(`${strangeValue} is not Signal.`);
    }
  }
};

Type Guard によって型の絞り込みが行われていくため、defaultブロックのなかのsignalgreenでもredでもない値、つまりyellowになっている。
にも関わらずnever型に代入しようとしているため、TypeScript はエラーを吐く。そのため、コードの実行前にgetMessageFromSignalの考慮漏れに気付くことができる。

yellowケースを追加すればdefaultブロック内のsignalneverとなるため、エラーは消える。

type Signal = 'green' | 'red' | 'yellow';

const getMessageFromSignal = (signal: Signal): string => {
  switch (signal) {
    case 'green': {
      return 'Go!';
    }
    case 'red': {
      return 'Stop!';
    }
    case 'yellow': {
      return 'Warning!';
    }
    default: {
      const strangeValue: never = signal;
      throw new Error(`${strangeValue} is not Signal.`);
    }
  }
};

console.log(getMessageFromSignal('yellow')); // Warning!

以降、Signalが追加される度にエラーが出るため、getMessageFromSignalの考慮漏れを見落とすことがなくなる。

Conditional Types と組み合わせる

neverは Conditional Types と組み合わせることで、型の絞り込みに利用できる。

Conditional Types に対して Union Types を使うと、Union Types のそれぞれの型に対して Conditional Types が展開される。
以下の例だとgreenblackそれぞれに対してIsSignalが実行される。

type Signal = 'green' | 'red' | 'yellow';
type IsSignal<T> = T extends Signal ? true : false;
type Foo = IsSignal<'green' | 'black'>; // type Foo = boolean

以下が、IsSignal<'green' | 'black'>booleanになるまでの流れ。

IsSignal<'green' | 'black'>
↓
('green' extends Signal ? true : false) | ('black' extends Signal ? true : false)
↓
true | false
↓
boolean

この性質をneverと組み合わせると、既存の型から特定の型をフィルタリングすることができる。

以下のSignalFilterは、Signalが渡されたときはそれをそのまま返し、Signal以外を渡された場合はneverを返す。
これを使うことで、Signal以外の値を取り除くことができる。

type Signal = 'green' | 'red' | 'yellow';
type SignalFilter<T> = T extends Signal ? T : never;
type Foo = SignalFilter<'green' | 'orange' | 'black' | 'yellow'>; // type Foo = "green" | "yellow"

上記のFooの場合、"green" | never | never | "yellow"となるが、Union Types のなかのneverは無視されるため、"green" | "yellow"となる。

このテクニックは TypeScript 本体でも使われており、NonNullableは以下の実装になっている。

type NonNullable<T> = T extends null | undefined ? never : T;

nullundefinedneverを返すため、それ以外の値が残る。

type Foo = NonNullable<null | 0 | 1>; // type Foo = 0 | 1
type Bar = NonNullable<null | '' | undefined | true>; // type Bar = "" | true

参考資料