TypeScript で enum(列挙型) っぽい定義をしたいときに使うテクニックをちゃんと理解する

TypeScriptでenumのような処理を書きたいとき、よく以下のようなテクニックが使われます。

export const Color = {
  RED: "red",
  GREEN: "green",
  BLUE: "blue",
} as const;

export type Color = typeof Color[keyof typeof Color];

これをちゃんと理解しようと思います。

TypeScriptにenum型はある

そもそもTypeScriptにenum型はあります。ただし、それを使わない風潮があります。

現在のTypeScriptの思想としては、「JavaScript + 型」というもので、あくまでJavaScriptであるという考え方ですすめられています。一方、過去のTypeScriptはそのような思想がありませんでした。

enumは「JavaScript + 型」という思想がなかった頃に作られた独自実装なので、イマドキのTypeScriptエンジニアはそれを避ける風潮があります。

雰囲気だけの問題ではなく、型安全性が守られないなどといった、実務的な問題も含まれています。

詳細は以下のページが詳しいです。

列挙型(enum)の問題点と代替手段 | TypeScript入門『サバイバルTypeScript』
TypeScriptの列挙型(enum)にはいくつか問題点が指摘されていてます。ここでは、その問題点と代替手段を説明します。

オブジェクトリテラルを使ってColorの値の定義

export const Color = {
  RED: "red",
  GREEN: "green",
  BLUE: "blue",
} as const;

JavaScriptではオブジェクトリテラル{}を用いることでオブジェクトを作成することができます。これを用いて、Colorオブジェクトを定義しています。

この定義によって、以下のコードが表現できるようになります。

Color.RED;   //  "red"
Color.BLUE;  //  "blue"
Color.GREEN; //  "green"

また、as constを用いることで、このColorオブジェクトを不変にしています。

Color型の定義

Colorオブジェクトだけでも使えないことはないのですが、このままだと型がないので引数で渡すときに都合が悪いです。

export const Color = {
  RED: "red",
  GREEN: "green",
  BLUE: "blue",
} as const;

// color が string型なのはそうオブジェクトを定義しただけであって、本質ではない
const fun = (color: string) => {return color;};
fun(Color.GREEN);

// こんな感じに呼びたい
const fun = (color: Color) => {return color;};
fun(Color.GREEN);

対応する型を定義したいですね。例えば、以下のようにunion型を定義すれば型は通ります。

export const Color = {
  RED: "red",
  GREEN: "green",
  BLUE: "blue",
} as const;

export type Color = "red" | "green" | "blue";

const fun = (color: Color) => {return color;};
fun(Color.GREEN);

だいぶ近いですね。でもこうするとColorオブジェクトに新しい色を追加するときにColor型も変更する必要があります。ちょっと面倒です。

ここで、最初の表現を見直してみます。

export const Color = {
  RED: "red",
  GREEN: "green",
  BLUE: "blue",
} as const;

export type Color = typeof Color[keyof typeof Color];

どうやら typeof Color[keyof typeof Color] という部分が、 "red" | "green" | "blue" を表現しているようです。

もう少し見てみましょう。

typeof型演算子

typeof型演算子は変数から型を抽出することができます。TypeScriptの強力な型推論によって実現されます。

以下の2つで定義したColor型は同じ結果になります。

const Color = {
  RED: "red",
  GREEN: "green",
  BLUE: "blue",
} as const;

type Color = typeof Color;
type Color = {
  RED: string,
  GREEN: string,
  BLUE: string,
}

keyof型演算子

keyof型はオブジェクトの型からオブジェクトのプロパティ名の型を取得するものです。

プロパティが複数ある場合はunion型で返されます。

const Color = {
  RED: "red",
  GREEN: "green",
  BLUE: "blue",
} as const;

type K = keyof typeof Color;
//以下と同じ定義になる
type K = "RED" | "GREEN" | "BLUE"

そうすると、typeof Color[keyof typeof Color]という部分は typeof Color["RED" | "GREEN" | "BLUE"] になります。

// この2つは同じ定義
type Color = typeof Color["RED" | "GREEN" | "BLUE"];
type Color = "red" | "green" | "blue";

typeofの挙動に慣れていないのでこの部分がつまづきましたが、考えるとだんだん実感してきます。よく考えられたテクニックだなと思いました。

定義がダブったとESLintに怒られたら

ESLintの設定によっては、型のColorと、値のColorが被っていることを警告する場合があります。

警告されるとドキドキしちゃいますが、型と値で同じ名前を使う手法はコンパニオンオブジェクトパターンと言って、立派なテクニックです。

コンパニオンオブジェクトパターン | TypeScript入門『サバイバルTypeScript』
TypeScriptでは値と型に同名を与えてその両方を区別なく使うことができるテクニックがあります。これをコンパニオンオブジェクトと呼びます。

disableを使ってESLintを黙らせれば良いです。

export const Color = {
  RED: "red",
  GREEN: "green",
  BLUE: "blue",
} as const;

// eslint-disable-next-line @typescript-eslint/no-redeclare
export type Color = typeof Color[keyof typeof Color];

参考文献

タイトルとURLをコピーしました