TypeScriptコードから一撃でenumを「追放」するツールを作った
TL; DR
TypeScriptのenumを等価なオブジェクトリテラル+union型の組に一括で書き換えるツール、その名もeject-enumを作りました。
コマンド一発で、あなたのTSコードベースからenumを「追放(eject)」できます。
npx eject-enum
背景
TypeScriptのenumを避けるべき理由についてはさんざん語り尽くされているので詳しくは説明しませんが、一言でいえば 「JavaScriptに型情報を付加するだけ」というTypeScriptの設計思想から逸脱する存在である、というのが最大の問題です。
最近のNode.jsではTypeScriptのコードをそのまま実行できるようになりましたが、これは内部的にはTypeScriptの型注釈を剥がしてJavaScriptとして実行しているにすぎません(type stripping)。ところが、enumをはじめとするTypeScript独自構文[1]は単純には剥がせないため、現在のNode.jsではそういった構文が使われたコードは実行できません。TypeScript 5.8では、Node.jsがそのまま実行できないコードをエラーとみなすコンパイラオプション erasableSyntaxOnly が導入される始末です。
さて、enum構文を使わずに列挙型を表現する手法はいくつかありますが、その中でenumを最も「忠実」に再現できるのは、以下のようにオブジェクトリテラルとそこから導出されるunion型を組み合わせる手法です。これはTypeScript Handbookでも紹介されており、広く受け入れられている印象です。
// オリジナルのenum
enum Direction {
Up,
Down,
Left,
Right,
}
// 「代替手法」で書き換えたもの
const Direction = {
Up: 0,
Down: 1,
Left: 2,
Right: 3,
} as const;
type Direction = (typeof Direction)[keyof typeof Direction]; // 0 | 1 | 2 | 3
これで万事解決―――本当にそうでしょうか?
特に、typeofやkeyofを使ってunion型を導出する部分はそれなりに複雑で、既存のコードベースにenumが大量に散らばっている場合、いちいち書き換えるのは非常に面倒です。また、生成AIにTypeScriptのコードを書かせたらenumを使ってきた、なんて話もちらほら耳にします。
この世のすべてのenumをまとめて葬り去る"力"が欲しい――― eject-enumは、そんなあなたの望みを叶えてくれます。
使い方
CLIツールとして
シンプルなプロジェクトであれば、npx eject-enumと唱えるだけでOKです。
デフォルトでは、カレントディレクトリにあるtsconfig.jsonがincludeするすべてのTypeScriptが書き換え対象となります。もちろん、pnpxやbunx、deno x(dx)などでも動作します。
オプションで、tsconfigのパスを指定したり、globにマッチするファイルだけを書き換え対象にしたり、逆に対象から除外したりもできます。
# tsconfigのパスを指定
npx eject-enum --project path/to/tsconfig.json
# globによるファイル指定
# srcディレクトリ以下の.tsファイルのうち、テストファイル以外のすべてを書き換える
npx eject-enum --include "src/**/*.ts" --exclude "src/**/*.test.ts"
ライブラリとして
eject-enumは、JS/TSスクリプトからライブラリとして使うこともできます。他の開発ツールとの統合などに使えるかもしれません。
npm install eject-enum
import { ejectEnum, EjectEnumTarget } from "eject-enum";
// tsconfigのパスを指定
await ejectEnum(
EjectEnumTarget.projects([
"path/to/tsconfig.json",
]),
);
// globによるファイル指定
// srcディレクトリ以下の.tsファイルのうち、テストファイル以外のすべてを書き換える
await ejectEnum(
EjectEnumTarget.srcPaths({
include: ["src/**/*.ts"],
exclude: ["src/**/*.test.ts"],
}),
);
アピールポイント
enumメンバーを型として参照するコードにも対応
enumのすべてのメンバーがリテラル型に推論できる場合、各メンバーをリテラル型のように使える仕様が存在します(enum member types)。例えば、discriminated unionのタグとしてenumメンバーを使ったりできます。
// Before
enum ShapeKind {
Circle = "circle",
Square = "square",
}
interface Circle {
kind: ShapeKind.Circle;
radius: number;
}
interface Square {
kind: ShapeKind.Square;
sideLength: number;
}
type Shape = Circle | Square;
単純にenum ShapeKindだけをオブジェクトに書き換えると、オブジェクトのフィールドは型としては使えないため、コードが壊れてしまいます。
eject-enumは、このような「enumメンバーをリテラル型として参照している部分」を検出し、typeofをつけることで、元のコードの挙動を維持します。
// After
const ShapeKind = {
Circle: "circle",
Square: "square",
} as const;
type ShapeKind = (typeof ShapeKind)[keyof typeof ShapeKind];
interface Circle {
kind: typeof ShapeKind.Circle;
radius: number;
}
interface Square {
kind: typeof ShapeKind.Square;
sideLength: number;
}
type Shape = Circle | Square;
低侵襲
eject-enumは、可能な限り既存のコードを尊重した書き換えを行うように設計されています。
-
enumの代わりとして単純なunion型を使う方法もあるが、eject-enumはあえてオブジェクトリテラル+union型の組を生成する。これは、(上記のような例外を除き)enumを使う側のコードを変更せずに済むようにするため -
enum自体や、各メンバーについたコメントをできるだけ保存する -
enumメンバーの値が定数式で初期化されている場合、元の定数式をコメントとして残す- オブジェクトリテラルのフィールドを定数式で初期化した場合リテラル型ではなく
numberやstring型に推論されてしまうため、そのまま書き換えられないという問題があり、それに対する苦肉の策
- オブジェクトリテラルのフィールドを定数式で初期化した場合リテラル型ではなく
// Before
enum ConstExprs {
A = 1 + 2 + 3,
B = "hello" + " " + "world",
}
// After
const ConstExprs = {
A: 6, // 1 + 2 + 3
B: "hello world", // "hello" + " " + "world"
} as const;
type ConstExprs = (typeof ConstExprs)[keyof typeof ConstExprs];
実装詳細、あるいはハマり話
ts-morphを使ってTypeScriptのASTをガリガリ書き換える形で実装しています。具体的にどんな実装になっているかについてはソースコードを覗いていただければと思います。
ts-morphでコードの書き換え処理を書く体験は、TypeScript Compiler APIを直接使って生ASTをそのまま扱うよりは間違いなくマシだが、Rustとかにあるリッチなマクロ機構を触ったことがある身としてはどこか物足りない、みたいなところです。特に、ASTを巨大な可変状態として扱うような作りになっており、ある書き換えが即座に反映されて後続の処理に影響を及ぼす点に苦労させられました。
例えば、enumメンバーをリテラル型として扱うコードを書き換えるにあたり、「enum本体」と「それを利用する箇所」の2箇所を変更する必要があるのですが、ここで「enum本体 → 利用箇所」の順で書き換えてしまうと、利用箇所の書き換え時点でenum本体がすでにオブジェクトリテラルに変わってしまっているため、正しく型参照を検出できなくなってしまいます。これに気づくまでに数時間かかりました…
余談: as enumは福音となるか?
オブジェクトリテラルにas enumというアサーションをつけられるようにして、これをつけるとTypeScript上でenumと同様に振る舞うようにしよう、という提案がなされています。
JavaScriptとして実行したければ単純にas enumを剥がすだけで済むという寸法ですね。現状のworkaroundに比べて見た目もスッキリしています。個人的にはかなり良いアイデアだと思うので、ぜひ採用されてほしいところです。
おわりに
TypeScriptのコードベースからenumを一掃するのに役立つツール、eject-enumの紹介でした。
実はこのツールは3年ほど前から作り始めて、中途半端な状態で放置していたものだったりします。type strippingでTypeScriptを任意の場所で動かそうという機運が高まっている今だからこそ輝くところがあるのではないかと思い、ここ数日で一気にfeature completeな状態に持っていきました。
ぜひ、eject-enumをTypeScriptコードベースの改善の一手にお役立てください。バグ報告や機能要望などもお待ちしております。
-
enumのほかに、namespaceやクラスのパラメータプロパティなどが該当 ↩︎
Discussion