Tackling TypeScript
この本のサポートをお願いします: 購入する または 寄付する
(広告です。ブロックしないでください。)

13 TypeScriptにおけるenumの代替手段



前の章では、TypeScriptのenumの仕組みについて説明しました。この章では、enumの代替手段について見ていきます。

13.1 シングルトン値のユニオン

enumは、メンバー名とメンバー値をマッピングします。その間接的な処理が必要ない場合、または望まない場合は、いわゆる*プリミティブリテラル型*のユニオンを使用できます - 値ごとに1つです。詳細に入る前に、プリミティブリテラル型について学ぶ必要があります。

13.1.1 プリミティブリテラル型

簡単な復習: 型は値の集合と考えることができます。

*シングルトン型*は、要素が1つだけの型です。プリミティブリテラル型はシングルトン型です

type UndefinedLiteralType = undefined;
type NullLiteralType = null;

type BooleanLiteralType = true;
type NumericLiteralType = 123;
type BigIntLiteralType = 123n; // --target must be ES2020+
type StringLiteralType = 'abc';

`UndefinedLiteralType`は、単一の要素`undefined`を持つ型です。

ここで作用する2つの言語レベルを認識することが重要です(これらのレベルには本書の前半ですでに遭遇しました)。次の変数宣言を考えてみましょう

const abc: 'abc' = 'abc';

プリミティブリテラル型の2つのユースケースは次のとおりです。

2番目のユースケースの詳細については、読み進めてください。

13.1.2 文字列リテラル型のユニオン

enumから始めて、文字列リテラル型のユニオンに変換します。

enum NoYesEnum {
  No = 'No',
  Yes = 'Yes',
}
function toGerman1(value: NoYesEnum): string {
  switch (value) {
    case NoYesEnum.No:
      return 'Nein';
    case NoYesEnum.Yes:
      return 'Ja';
  }
}
assert.equal(toGerman1(NoYesEnum.No), 'Nein');
assert.equal(toGerman1(NoYesEnum.Yes), 'Ja');

`NoYesStrings`は`NoYesEnum`のユニオン型バージョンです

type NoYesStrings = 'No' | 'Yes';

function toGerman2(value: NoYesStrings): string {
  switch (value) {
    case 'No':
      return 'Nein';
    case 'Yes':
      return 'Ja';
  }
}
assert.equal(toGerman2('No'), 'Nein');
assert.equal(toGerman2('Yes'), 'Ja');

型`NoYesStrings`は、文字列リテラル型`'No'`と`'Yes'`のユニオンです。ユニオン型演算子`|`は、集合論的ユニオン演算子`∪`に関連しています。

13.1.2.1 文字列リテラル型のユニオンは、網羅性のチェックが可能です

次のコードは、文字列リテラル型のユニオンに対して網羅性チェックが機能することを示しています

// @ts-expect-error: Function lacks ending return statement and
// return type does not include 'undefined'. (2366)
function toGerman3(value: NoYesStrings): string {
  switch (value) {
    case 'Yes':
      return 'Ja';
  }
}

`'No'`のケースを忘れてしまい、TypeScriptは関数が文字列ではない値を返す可能性があると警告しています。

網羅性をより明示的にチェックすることもできました

class UnsupportedValueError extends Error {
  constructor(value: never) {
    super('Unsupported value: ' + value);
  }
}

function toGerman4(value: NoYesStrings): string {
  switch (value) {
    case 'Yes':
      return 'Ja';
    default:
      // @ts-expect-error: Argument of type '"No"' is not
      // assignable to parameter of type 'never'. (2345)
      throw new UnsupportedValueError(value);
  }
}

これで、`value`が`'No'`の場合に`default`ケースに到達すると、TypeScriptが警告を発します。

  **網羅性チェックの詳細**

このトピックの詳細については、§12.7.2.2「網羅性チェックによるケースの忘れ防止」を参照してください。

13.1.2.2 欠点: 文字列リテラルのユニオンは型安全性に劣る

文字列リテラルユニオンの1つの欠点は、メンバーではない値がメンバーと間違われる可能性があることです

type Spanish = 'no' | 'sí';
type English = 'no' | 'yes';

const spanishWord: Spanish = 'no';
const englishWord: English = spanishWord;

これは、スペイン語の`'no'`と英語の`'no'`は同じ値であるため、論理的です。実際の問題は、それらに異なるアイデンティティを与える方法がないことです。

13.1.3 シンボルシングルトン型のユニオン

13.1.3.1 例: `LogLevel`

文字列リテラル型のユニオンの代わりに、シンボルシングルトン型のユニオンを使用することもできます。今回は別のenumから始めましょう

enum LogLevel {
  off = 'off',
  info = 'info',
  warn = 'warn',
  error = 'error',
}

シンボルシングルトン型のユニオンに変換すると、次のようになります

const off = Symbol('off');
const info = Symbol('info');
const warn = Symbol('warn');
const error = Symbol('error');

// %inferred-type: unique symbol | unique symbol |
// unique symbol | unique symbol
type LogLevel =
  | typeof off
  | typeof info
  | typeof warn
  | typeof error
;

なぜここで`typeof`が必要なのでしょうか? `off`などは値であり、型方程式には現れません。型演算子`typeof`は、値を型に変換することでこの問題を解決します。

前の例の2つのバリエーションを考えてみましょう。

13.1.3.2 バリエーション#1: インライン化されたシンボル

シンボルをインライン化できますか(個別の`const`宣言を参照する代わりに)?残念ながら、型演算子`typeof`のオペランドは、識別子またはドットで区切られた識別子の「パス」でなければなりません。したがって、この構文は不正です

type LogLevel = typeof Symbol('off') | ···
13.1.3.3 バリエーション#2: `const`の代わりに`let`

変数を宣言するために`const`の代わりに`let`を使用できますか?(必ずしも改善ではありませんが、それでも興味深い質問です。)

`const`で宣言された変数に対してTypeScriptが推論する、より狭い型が必要なため、できません。

// %inferred-type: unique symbol
const constSymbol = Symbol('constSymbol');

// %inferred-type: symbol
let letSymbol1 = Symbol('letSymbol1');

`let`を使用すると、`LogLevel`は単に`symbol`のエイリアスになります。

`const`アサーションは通常、この種の問題を解決します。しかし、この場合は機能しません

// @ts-expect-error: A 'const' assertions can only be applied to references to enum
// members, or string, number, boolean, array, or object literals. (1355)
let letSymbol2 = Symbol('letSymbol2') as const;
13.1.3.4 関数での`LogLevel`の使用

次の関数は、`LogLevel`のメンバーを文字列に変換します

function getName(logLevel: LogLevel): string {
  switch (logLevel) {
    case off:
      return 'off';
    case info:
      return 'info';
    case warn:
      return 'warn';
    case error:
      return 'error';
  }
}

assert.equal(
  getName(warn), 'warn');
13.1.3.5 シンボルシングルトン型のユニオン vs. 文字列リテラル型のユニオン

2つのアプローチはどのように比較されますか?

スペイン語の`'no'`が英語の`'no'`と混同されたこの例を思い出してください

type Spanish = 'no' | 'sí';
type English = 'no' | 'yes';

const spanishWord: Spanish = 'no';
const englishWord: English = spanishWord;

シンボルを使用すると、この問題は発生しません

const spanishNo = Symbol('no');
const spanishSí = Symbol('sí');
type Spanish = typeof spanishNo | typeof spanishSí;

const englishNo = Symbol('no');
const englishYes = Symbol('yes');
type English = typeof englishNo | typeof englishYes;

const spanishWord: Spanish = spanishNo;
// @ts-expect-error: Type 'unique symbol' is not assignable to type 'English'. (2322)
const englishWord: English = spanishNo;

13.1.4 このセクションの結論: ユニオン型 vs. enum

ユニオン型とenumには、いくつかの共通点があります

しかし、それらはまた異なります。シンボルシングルトン型のユニオンの欠点は次のとおりです。

シンボルシングルトン型のユニオンの長所は次のとおりです。

13.2 判別可能なユニオン

判別可能なユニオンは、関数型プログラミング言語の代数データ型に関連しています。

それらがどのように機能するかを理解するために、次のような式を表すデータ構造*構文木*を考えてみましょう。

1 + 2 + 3

構文木は次のいずれかです。

次のステップ

  1. まず、構文木のオブジェクト指向クラス階層を作成します。
  2. 次に、それをもう少し機能的なものに変換します。
  3. そして最後に、判別可能なユニオンになります。

13.2.1 ステップ1: クラス階層としての構文木

これは、構文木の典型的なオブジェクト指向実装です

// Abstract = can’t be instantiated via `new`
abstract class SyntaxTree1 {}
class NumberValue1 extends SyntaxTree1 {
  constructor(public numberValue: number) {
    super();
  }
}
class Addition1 extends SyntaxTree1 {
  constructor(public operand1: SyntaxTree1, public operand2: SyntaxTree1) {
    super();
  }
}

`SyntaxTree1`は、`NumberValue1`と`Addition1`のスーパークラスです。キーワード`public`は、以下の糖衣構文です。

これは、`SyntaxTree1`を使用する例です

const tree = new Addition1(
  new NumberValue1(1),
  new Addition1(
    new NumberValue1(2),
    new NumberValue1(3), // trailing comma
  ), // trailing comma
);

注: 引数リストの末尾のカンマは、ECMAScript 2016以降のJavaScriptで許可されています。

13.2.2 ステップ2: クラスのユニオン型としての構文木

ユニオン型(行A)を介して構文木を定義する場合、オブジェクト指向継承は必要ありません

class NumberValue2 {
  constructor(public numberValue: number) {}
}
class Addition2 {
  constructor(public operand1: SyntaxTree2, public operand2: SyntaxTree2) {}
}
type SyntaxTree2 = NumberValue2 | Addition2; // (A)

`NumberValue2`と`Addition2`にはスーパークラスがないため、コンストラクターで`super()`を呼び出す必要はありません。

興味深いことに、以前と同じ方法でツリーを作成します

const tree = new Addition2(
  new NumberValue2(1),
  new Addition2(
    new NumberValue2(2),
    new NumberValue2(3),
  ),
);

13.2.3 ステップ3: 判別可能なユニオンとしての構文木

最後に、判別可能なユニオンについて説明します。これらは`SyntaxTree3`の型定義です

interface NumberValue3 {
  kind: 'number-value';
  numberValue: number;
}
interface Addition3 {
  kind: 'addition';
  operand1: SyntaxTree3;
  operand2: SyntaxTree3;
}
type SyntaxTree3 = NumberValue3 | Addition3;

クラスからインターフェースに切り替え、したがってクラスのインスタンスからプレーンオブジェクトに切り替えました。

判別可能なユニオンのインターフェースは、少なくとも1つのプロパティを共通して持ち、そのプロパティはそれぞれに対して異なる値を持つ必要があります。そのプロパティは、*判別子*または*タグ*と呼ばれます。 `SyntaxTree3`の判別子は`.kind`です。その型は文字列リテラル型です。

比較

これは、`SyntaxTree3`に一致するオブジェクトです

const tree: SyntaxTree3 = { // (A)
  kind: 'addition',
  operand1: {
    kind: 'number-value',
    numberValue: 1,
  },
  operand2: {
    kind: 'addition',
    operand1: {
      kind: 'number-value',
      numberValue: 2,
    },
    operand2: {
      kind: 'number-value',
      numberValue: 3,
    },
  }
};

行Aの型注釈は必要ありませんが、データが正しい構造になっていることを確認するのに役立ちます。ここで行わないと、後で問題が発生します。

次の例では、`tree`の型は判別可能なユニオンです。判別子(行C)をチェックするたびに、TypeScriptはそれに応じて静的型を更新します

function getNumberValue(tree: SyntaxTree3) {
  // %inferred-type: SyntaxTree3
  tree; // (A)

  // @ts-expect-error: Property 'numberValue' does not exist on type 'SyntaxTree3'.
  // Property 'numberValue' does not exist on type 'Addition3'.(2339)
  tree.numberValue; // (B)

  if (tree.kind === 'number-value') { // (C)
    // %inferred-type: NumberValue3
    tree; // (D)
    return tree.numberValue; // OK!
  }
  return null;
}

行Aでは、まだ判別子`.kind`をチェックしていません。したがって、`tree`の現在の型はまだ`SyntaxTree3`であり、行Bのプロパティ`.numberValue`にアクセスできません(ユニオンの型の1つだけがこのプロパティを持っているためです)。

行Dでは、TypeScriptは`.kind`が`'number-value'`であることを認識しており、したがって`tree`の型`NumberValue3`を推測できます。そのため、次の行で`.numberValue`にアクセスすることは、今回は問題ありません。

13.2.3.1 判別可能なユニオンの関数の 実装

このステップは、判別可能なユニオンの関数をどのように実装するかの例で締めくくります.

すべてのサブタイプのメンバーに適用できる操作がある場合、クラスと判別可能なユニオンのアプローチは異なります

次の例は、関数型アプローチを示しています。判別子は行Aで検査され、2つの`switch`ケースのどちらが実行されるかが決定されます。

function syntaxTreeToString(tree: SyntaxTree3): string {
  switch (tree.kind) { // (A)
    case 'addition':
      return syntaxTreeToString(tree.operand1)
        + ' + ' + syntaxTreeToString(tree.operand2);
    case 'number-value':
      return String(tree.numberValue);
  }
}

assert.equal(syntaxTreeToString(tree), '1 + 2 + 3');

TypeScriptは、判別可能なユニオンに対して網羅性チェックを実行することに注意してください。ケースを忘れると、TypeScriptは警告を発します。

これは、前のコードのオブジェクト指向バージョンです

abstract class SyntaxTree1 {
  // Abstract = enforce that all subclasses implement this method:
  abstract toString(): string;
}
class NumberValue1 extends SyntaxTree1 {
  constructor(public numberValue: number) {
    super();
  }
  toString(): string {
    return String(this.numberValue);
  }
}
class Addition1 extends SyntaxTree1 {
  constructor(public operand1: SyntaxTree1, public operand2: SyntaxTree1) {
    super();
  }
  toString(): string {
    return this.operand1.toString() + ' + ' + this.operand2.toString();
  }
}

const tree = new Addition1(
  new NumberValue1(1),
  new Addition1(
    new NumberValue1(2),
    new NumberValue1(3),
  ),
);

assert.equal(tree.toString(), '1 + 2 + 3');
13.2.3.2 拡張性: オブジェクト指向アプローチ vs. 関数型アプローチ

それぞれのアプローチは、ある種の拡張性をうまく行います

13.2.4 判別可能なユニオン vs. 通常のユニオン型

判別可能なユニオンと通常のユニオン型には、2つの共通点があります

次の2つの小節では、通常のユニオンよりも識別ユニオンが持つ2つの利点について説明します。

13.2.4.1 利点:記述的なプロパティ名

識別ユニオンを使用すると、値に記述的なプロパティ名が付けられます。比較してみましょう。

通常のユニオン

type FileGenerator = (webPath: string) => string;
type FileSource1 = string|FileGenerator;

識別ユニオン

interface FileSourceFile {
  type: 'FileSourceFile',
  nativePath: string,
}
interface FileSourceGenerator {
  type: 'FileSourceGenerator',
  fileGenerator: FileGenerator,
}
type FileSource2 = FileSourceFile | FileSourceGenerator;

ソースコードを読む人は、文字列がネイティブパス名であることをすぐに理解できます。

13.2.4.2 利点:部分が区別できない場合にも使用できます

TypeScriptではユニオンの型を区別できないため、次の識別ユニオンは通常のユニオンとして実装できません。

interface TemperatureCelsius {
  type: 'TemperatureCelsius',
  value: number,
}
interface TemperatureFahrenheit {
  type: 'TemperatureFahrenheit',
  value: number,
}
type Temperature = TemperatureCelsius | TemperatureFahrenheit;

13.3 オブジェクトリテラルを列挙型として使用

列挙型を実装するための次のパターンは、JavaScriptでよく使用されます。

const Color = {
  red: Symbol('red'),
  green: Symbol('green'),
  blue: Symbol('blue'),
};

TypeScriptでは、次のように使用を試みることができます。

// %inferred-type: symbol
Color.red; // (A)

// %inferred-type: symbol
type TColor2 = // (B)
  | typeof Color.red
  | typeof Color.green
  | typeof Color.blue
;

function toGerman(color: TColor): string {
  switch (color) {
    case Color.red:
      return 'rot';
    case Color.green:
      return 'grün';
    case Color.blue:
      return 'blau';
    default:
      // No exhaustiveness check (inferred type is not `never`):
      // %inferred-type: symbol
      color;

      // Prevent static error for return type:
      throw new Error();
  }
}

しかし、`Color` の各プロパティの型は `symbol`(A行)であり、`TColor`(B行)は `symbol` のエイリアスです。その結果、`toGerman()` に任意のシンボルを渡すことができ、TypeScriptはコンパイル時にエラーを出しません。

assert.equal(
  toGerman(Color.green), 'grün');
assert.throws(
  () => toGerman(Symbol())); // no static error!

`const` アサーションはこの種の問題に役立つことがよくありますが、今回は役に立ちません。

const ConstColor = {
  red: Symbol('red'),
  green: Symbol('green'),
  blue: Symbol('blue'),
} as const;

// %inferred-type: symbol
ConstColor.red;

これを修正する唯一の方法は、定数を使用することです。

const red = Symbol('red');
const green = Symbol('green');
const blue = Symbol('blue');

// %inferred-type: unique symbol
red;

// %inferred-type: unique symbol | unique symbol | unique symbol
type TColor2 = typeof red | typeof green | typeof blue;

13.3.1 文字列値のプロパティを持つオブジェクトリテラル

const Color = {
  red: 'red',
  green: 'green',
  blue: 'blue',
} as const; // (A)

// %inferred-type: "red"
Color.red;

// %inferred-type: "red" | "green" | "blue"
type TColor =
  | typeof Color.red
  | typeof Color.green
  | typeof Color.blue
;

`Color` のプロパティがより一般的な型 `string` を持たないように、A行に `as const` が必要です。すると、`TColor` も `string` よりも具体的な型になります。

シンボル値のプロパティを持つオブジェクトを列挙型として使用する場合と比較して、文字列値のプロパティは次のとおりです。

13.3.2 オブジェクトリテラルを列挙型として使用することの長所と短所

長所

短所

13.4 列挙型パターン

次の例は、プレーンなJavaScriptとTypeScriptで動作する Java風の列挙型パターン を示しています。

class Color {
  static red = new Color();
  static green = new Color();
  static blue = new Color();
}

// @ts-expect-error: Function lacks ending return statement and return type
// does not include 'undefined'. (2366)
function toGerman(color: Color): string { // (A)
  switch (color) {
    case Color.red:
      return 'rot';
    case Color.green:
      return 'grün';
    case Color.blue:
      return 'blau';
  }
}

assert.equal(toGerman(Color.blue), 'blau');

残念ながら、TypeScriptは網羅性チェックを実行しないため、A行でエラーが発生します。

13.5 列挙型と列挙型の代替案のまとめ

次の表は、TypeScriptにおける列挙型とその代替案の特性をまとめたものです。

一意性 名前空間 反復 メンバーシップチェック(コンパイル時) メンバーシップチェック(実行時) 網羅性
数値列挙型 - -
文字列列挙型 -
文字列ユニオン - - - -
シンボルユニオン - - -
識別ユニオン - (1) - - - (2)
シンボルプロパティ - - -
文字列プロパティ - -
列挙型パターン -

表の列のタイトル

表のセル内の脚注

  1. 識別ユニオンは実際には一意ではありませんが、値をユニオンメンバーと間違えることは比較的まれです(特に、識別プロパティに一意の名前を使用する場合)。
  2. 識別プロパティに十分に一意な名前がある場合、メンバーシップのチェックに使用できます。

13.6 謝辞