くらしのマーケット開発ブログ

「くらしのマーケット」を運営する、みんなのマーケット株式会社のテックブログ。積極採用中です。

Web ナイト宮崎 Vol.10 ~てげ TypeScript を学びたい~ で登壇しました

f:id:curama-tech:20201009174601p:plain

こんにちは、バックエンドエンジニアの @akira です。

9 月 18 日に宮崎で開催された勉強会【Web ナイト宮崎 Vol.10 ~てげ TypeScript を学びたい~】で「TypeScript の列挙型について」というテーマで発表してきました!

今回はその内容を記事として再編成・加筆したものになります。


はじめに

今回は 「TypeScript Enum 〜使いどころの難しい Enum ??〜」 というテーマで発表させて頂きます。

発表内容は以下の二点です。

  • TypeScript Enum の基礎
  • 複雑な Enum の表現方法

Agenda は次のようになっています。

  • Numeric Enum
  • Union of Literal Type
  • String Enum
  • Const Enum
  • Namespace
  • Enum Class
  • 制約まとめ

この後提示するコードは全て TypeScript Playground v4.0.2 にて確認しています。

それでは早速 Numeric Enum から見ていきましょう。

Numeric Enum

Numeric Enum とは number 型の値をもつ Enum です。

enum Fruits {
  Apple,
  Orange,
  Lemon,
}

let fruit = Fruits.Apple;
console.log(fruit); // 0
fruit = 1; // number is also acceptable
fruit = 4; // not error

number 型の値なら代入できてしまうため、型安全ではありません。
Enum で指定した値以外の number も代入が可能です。

string 型は代入できません。

fruit = "Apple"; // Error: Type '"Apple"' is not assignable to type 'Fruits'.

トランスパイルされた JavaScript を見てみます。

// JS
var Fruits;
(function (Fruits) {
  Fruits[(Fruits["Apple"] = 0)] = "Apple";
  Fruits[(Fruits["Orange"] = 1)] = "Orange";
  Fruits[(Fruits["Lemon"] = 2)] = "Lemon";
})(Fruits || (Fruits = {}));

Fruits["Apple"] = 0 は 0 を返し(reverse mapping)、Value と Key のどちらも指定が可能です。
範囲外の値を指定した場合は undefined が返されます。

console.log(Fruits[0]); // "Apple"
console.log(Fruits["Apple"]); // 0
console.log(Fruits.Apple); // 0
console.log(Fruits[10]); // undefined

また、値の初期値は明示的に指定可能です。

enum Fruits {
  Apple = 1,
  Orange, // 2
  Lemon, // 3
}

console.log(Fruits.Orange); // 2

Union of Literal Type

リテラル型の Union 型は指定した値以外は代入できないため、Numeric Enum に代わる良い選択肢となるでしょう。

const Fruit = {
  Apple: 0,
  Orange: 1,
  Lemon: 2,
} as const;
type Fruit = typeof Fruit[keyof typeof Fruit]; // 0 | 1 | 2

let f: Fruit;
f = 0; // OK
f = Fruit.Lemon; // OK
f = 4; // Error: Type '4' is not assignable to type 'Fruit'.

トランスパイルされた JavaScript はとてもシンプルです。

// JS
const Fruit = {
  Apple: 0,
  Orange: 1,
  Lemon: 2,
};

String Enum

続いて String Enum です。
String Enum は string 型の値をもつ Enum ですが、string 型自体は代入できないため、型安全です。

enum Fruits {
  Apple = "Apple",
  Orange = "Orange",
  Lemon = "Lemon",
}

let fruit = Fruits.Apple;
fruit = "Orange"; // Error: Type '"Orange"' is not assignable to type 'Fruits'

トランスパイルされた JavaScript を見てみます。

// JS
var Fruits;
(function (Fruits) {
  Fruits["Apple"] = "Apple";
  Fruits["Orange"] = "Orange";
  Fruits["Lemon"] = "Lemon";
})(Fruits || (Fruits = {}));

reverse mapping もされておらず、Key のみ指定可能です。

enum Fruits {
  Apple = "Apple",
  Orange = "Orange",
  Lemon = "Lemon",
}

console.log(Fruits["Apple"]); // "Apple"
console.log(Fruits.Apple); // "Apple"

Value を Key と同じにすることで、可読性が上がり、debug しやすいのではないでしょうか。

Const Enum

次は Const Enum です。

Const Enum は、Numeric Enumconst キーワードが付与された Enum です。

const enum Fruits {
  Apple,
  Orange,
  Lemon,
}

Const Enum の特徴を説明する前に、先程の Numeric Enum のトランスパイル後の JavaScript をもう一度見てみましょう。

enum Fruits {
  Apple,
  Orange,
  Lemon,
}

const apple = Fruits.Apple;
// JS
var Fruits;
(function (Fruits) {
  Fruits[(Fruits["Apple"] = 0)] = "Apple";
  Fruits[(Fruits["Orange"] = 1)] = "Orange";
  Fruits[(Fruits["Lemon"] = 2)] = "Lemon";
})(Fruits || (Fruits = {}));

const apple = Fruits.Apple;

上記を見ると、即時関数によってオブジェクトを変数に代入していることがわかります。

Const Enum を使った以下のコードの、トランスパイル後の JavaScript を見てみます。

const enum Fruits {
  Apple,
  Orange,
  Lemon,
}

const apple = Fruits.Apple;
// JS
const apple = 0; /* Apple */

Numeric Enum と比べ、値がインライン化されていることがわかります。よってパフォーマンスの向上が期待できます。

ただし、Const Enum も Numeric Enum 同様、型安全ではありません。

const enum Fruits {
  Apple,
  Orange,
  Lemon,
}

let fruit = Fruits.Apple;
fruit = 1; // number is also acceptable
fruit = 10; // not error

Namespace

Namespace を使うと、Enum にメソッドを追加することができます。

enum Fruits {
  Apple,
  Orange,
  Lemon,
}

namespace Fruits {
  export function isApple(fruit: Fruits): boolean {
    switch (fruit) {
      case Fruits.Apple:
        return true;
      default:
        return false;
    }
  }
}

ただし、Const Enum には Namespace を定義できません。

const enum Fruits { // Error: Enum declarations can only merge with namespace or other enum declarations.
  Apple,
  Orange,
  Lemon,
}

// Error: Enum declarations can only merge with namespace or other enum declarations.
namespace Fruits {
  export function isApple(fruit: Fruits): boolean {
    switch (fruit) {
      case Fruits.Apple:
        return true;
      default:
        return false;
    }
  }
}

Enum Class

ここまで一通り Enum を見てきましたが、Numeric Enum や Const Enum には制約があったかと思います。
メソッドを持つ少し複雑な Enum を表現したい場合、弊社では Enum 用の Class を定義しています。

まず Enum の抽象クラスを定義します。

abstract class Enum<T> {
  constructor(readonly value: T) {}

  public is(e: Enum<T>): boolean {
    return this.value === e.value;
  }

  public isNot(e: Enum<T>): boolean {
    return !this.is(e);
  }
}

続いて具象クラスを String Enum と共に定義します。

enum FruitNames {
  Apple = "Apple",
  Orange = "Orange",
}

class Fruits extends Enum<string> {
  public static [FruitNames.Apple]: Fruits = new Fruits(FruitNames.Apple, 100, 0.5);
  public static [FruitNames.Orange]: Fruits = new Fruits(FruitNames.Orange, 200, 1);

  constructor(value: string, readonly price: number, readonly weight: number) {
    super(value);
  }
}

定数は String Enum に、ロジックは Class に定義します。
また、Static Field として各オブジェクトを保持することで Enum のように扱えます。

上記の例では、りんごやオレンジなどの果物に対し、料金と重さの紐付けを表現しています。

クライアントコードは次のようになります。

let fruit = Fruits.Orange;
if (fruit.is(Fruits.Orange)) {
  console.log(fruit.price, fruit.weight);
}
fruit = "Orange"; // Error: Type 'string' is not assignable to type 'Fruits'.

上記のように、string 型の値は代入できないため、型安全です。

制約まとめ

それぞれ詳細を見てきましたが、制約をまとめると以下になります。

範囲外の値を代入できない 型安全
Numeric Enum
Union of Literal Type
String Enum
Const Enum
Enum Class

最後に

今回のまとめです。

  • Enum は万能ではない
  • Union of Literal Type も良い選択肢である
  • 複雑な Enum は Class を使うのがおすすめ

以上になります。ありがとうございました!