さようなら、TypeScript enum

2020/02/28
このエントリーをはてなブックマークに追加
ありがとう…看護師…

フロントエンドエンジニアの今村です。TypeScriptではenumを使わずunion型を使いましょう、という話を書きます。

モチベーション

何を今さら、と思う方もいるかもしれません。

TypeScriptのunion型はenum的なものを表現可能であり、基本的にenumよりもunion型を使うべき、という意識を持っているTypeScriptプログラマーはすでに少なからずいるのではないかと思います。しかし、ではenumの使用はいかなる場合も避けるべきなのか、そうでないとしたらどのような基準でenumとunion型を使い分けるべきなのか、といった点について、広く合意の取れたガイドラインはなさそうです(少なくとも私は知りません)。この結果、コードレビューなどで少しやりづらさを感じることがあったので、白黒つけてしまいたいという気持ちからこのブログを書いています。

結論としては、enumは全面的に禁止し、常にunion型を使うのが分かりやすいと考えます。TypeScriptの世界でこの考えに一定の合意が得られ、プログラマーが当たり前のようにリンターでenumの使用を禁止し、enumかunion型かで悩んだり、コードレビューで議論したりすることがなくなればいいと思います。

Stack Overflowでも、enumとunion型の使い分けについての質問があったため、すでに同様の内容を回答しました。もし同意いただけたら、Stack Overflowの方もupvoteしてもらえると嬉しいです。enum廃絶の輪を世界に広げたい。

基礎知識

enum、const enum、union型について簡単に説明します。

コンパイル前:

// 数値のenum
enum NumberEnum {
// 数値は明示的に割り当てることも可能
Foo, Bar, Baz
}
const numberEnum: NumberEnum = NumberEnum.Foo;
// 文字列のenum
enum StringEnum {
Foo = 'foo',
Bar = 'bar',
Baz = 'baz'
}
const stringEnum = StringEnum.Foo;
// const enum
// (文字列も利用可能だが、コンパイルに関して挙動の違いはないため数値の例のみ)
const enum ConstEnum {
Foo,
Bar,
Baz
}
const constEnum = ConstEnum.Foo;
// union型
// (文字列も利用可能だが、コンパイルに関して挙動の違いはないため数値の例のみ)
type Union = 0 | 1 | 2;
const union: Union = 0;

コンパイル後:

// 数値のenum
// enumのメンバーの名前と値を双方向にマップするようなJavaScriptオブジェクトが生成される
var NumberEnum;
(function (NumberEnum) {
NumberEnum[NumberEnum["Foo"] = 0] = "Foo";
NumberEnum[NumberEnum["Bar"] = 1] = "Bar";
NumberEnum[NumberEnum["Baz"] = 2] = "Baz";
})(NumberEnum || (NumberEnum = {}));
const numberEnum = NumberEnum.Foo;
// 文字列のenum
// enumのメンバーの名前をキーとし、値を値とするJavaScriptオブジェクトが生成される
var StringEnum;
(function (StringEnum) {
StringEnum["Foo"] = "foo";
StringEnum["Bar"] = "bar";
StringEnum["Baz"] = "baz";
})(StringEnum || (StringEnum = {}));
const stringEnum = StringEnum.Foo;
// const enum
// const enumの宣言自体はJavaScriptのコードに何も出力しない
// const enumの利用箇所で値がインライン化される
const constEnum = 0 /* Foo */;
// union型
// union型の宣言自体はJavaScriptのコードに何も出力しない
const union = 0;

enumはunion型に置き換え可能か

enumを使わないとして、実際にすべてのenumはunion型に置き換え可能なのでしょうか?

反復処理可能なunion型

先述のStack Overflowの質問に対して、accepted answerを含むいくつかの回答で指摘されているのが、union型ではそれに含まれるすべての値に対して反復処理を記述することができない、というものです。

これらの回答がなされた時点では、それはある程度正しかったのですが、TypeScript 3.4で登場したconstアサーション(as constにより、状況は変わりました。現在では、反復処理可能なunion型を簡単に宣言できます。

const permissions = ['read', 'write', 'execute'] as const;
type Permission = typeof permissions[number]; // 'read' | 'write' | 'execute'
// 反復処理
for (const permission of permissions) {
// 何かやる
}

先に配列を定義してしまって、そこに含まれる要素からunion型を宣言するという方法です。

union型のそれぞれの値に名前をつける

as constはさらに、union型の値そのものだけでは意味が不明瞭な場合に、分かりやすい名前をつけるようなことも可能にしました(ただし、少し手のこんだことをすればas constを使わなくても同様のことが実現できるようです)。

// enumを使う場合
enum Permission {
Read = 'r',
Write = 'w',
Execute = 'x'
}
// union型を使う場合
const Permission = {
Read: 'r',
Write: 'w',
Execute: 'x'
} as const;
type Permission = typeof Permission[keyof typeof Permission]; // 'r' | 'w' | 'x'
// もちろん反復処理可能
for (const permission of Object.values(Permission)) {
// 何かやる
}

enumの「不透明性」はunion型では実現できない

一方、union型では実現できないenumならではの特徴も残念ながら存在します。文字列のenumを使うとき、ある文字列や文字列リテラルがenumに含まれることが文脈的に明らかであったとしても、それらをenumに割り当てることはできません。

enum StringEnum {
Foo = 'foo'
}
const foo1: StringEnum = StringEnum.Foo; // no error
const foo2: StringEnum = 'foo'; // error!!

つまり、enum型への値の割り当てでは(アサーションなどを使わない限り)そのenumの値を使うことが強制され、スタイルを統一できます。このような割り当て可能性の挙動は、TypeScriptが構造的型付けを採用していることを考えるとやや奇妙であり、修正すべきであるとするissueも過去には上げられました(これこれ)。これらのissueでこの挙動に対する説明として繰り返し述べられたのは、文字列のenumは「不透明性」を実現するためのものである、ということです。つまり、何かの事情でenumの値の修正が必要になった場合に、この挙動のおかげでその影響範囲を小さくできます。

enum Weekend {
Saturday = 'Saturday',
Sunday = 'Sunday'
}
// 直接'Saturday'のような値が割り当てられることはないため、
// 将来Weekend.Saturdayの値が'Sat'になっても変更不要
const weekend: Weekend = Weekend.Saturday;

ただし、この「不透明性」は完全ではなく、片手落ちのやや中途半端なものになってしまっていることには注意が必要です。つまり、上記と逆方向の割り当ては制限されていないのです。

enum Weekend {
Saturday = 'Saturday',
Sunday = 'Sunday'
}
// 将来Weekend.Saturdayの値が'Sat'になると変更が必要
const saturday: 'Saturday' = Weekend.Saturday;

この「不透明性」のメリットが、enumのデメリットを上回ると考えるなら、enumは捨てられないことになります。それを判断するために、enumのデメリットを見ていきましょう。

enumのデメリット

1. constでないenumはTypeScriptの”a typed superset of JavaScript”というコンセプトにそぐわない

このコンセプトは、TypeScriptが他のaltJSを差し置いて大きな支持を得た決定的な要因の1つと言えるでしょう。constでないenumは、JavaScriptと互換性のない構文で実行時に存在するJavaScriptオブジェクトを生成することで、このコンセプトに違反します。

2. const enumには落とし穴がある

2-1. const enumはBabelでトランスパイルできない

この問題については、今のところ2つのworkaroundがあります。1つは手でconst enumを通常のenumに書き換えるというもの、もう1つはそれをbabel-plugin-const-enumというプラグインを使ってトランスパイルの過程で自動的に行うというものです。

2-2. アンビエントコンテキストにおけるconst enumの使用は問題になりうる

--isolatedModulesコンパイルオプションを有効にする場合、アンビエントコンテキスト(*.d.tsファイルの中やdeclare構文)で宣言されたconst enumに別モジュール(別ファイル)からアクセスするとコンパイルエラーになります。そのconst enumを内部的にしか利用せず、そのコードがコンパイルされる際のオプションを100%制御できるならいいですが、そうでないなら問題になります。

したがって、enumを実際に使わないようにするかどうかは結局のところ好みの問題もあるでしょうが、npmに公開する*.d.tsでconst enumをexportしたりすることに関しては明確に誤りと言えます。TypeScriptチームのメンバーもこのことを踏まえ、“const enum on DT really does not make sense”(DTはDefinitelyTyped)であり、アンビエントコンテキストにおいては“You should use a union type of literals (string or number) instead.”であると述べています。

2-3. --isolatedModulesが有効な場合のconst enumの挙動はアンビエントコンテキストの外でもおかしい

GitHubのコメントを読んで知ったのですが、--isolatedModulesを有効にしてconst enumをコンパイルすると次のようになります。

コンパイル前:

/// a.ts
export const enum A {
B
}
export const enum A {
C = 2
}
/// b.ts
import { A } from './a'
console.log(A.B, A.C)

コンパイル後:

/// a.js
export var A;
(function (A) {
A[A["B"] = 0] = "B";
})(A || (A = {}));
(function (A) {
A[A["C"] = 2] = "C";
})(A || (A = {}));
//# sourceMappingURL=a.js.map
/// b.js
console.log(A.B, A.C);
//# sourceMappingURL=b.js.map

--isolatedModulesは、コンパイルを1モジュール(1ファイル)ずつ行えるようにするためのオプションなので、const enumの出力結果が通常のenumのようになるのは想定の範囲内です。別モジュール(別ファイル)に存在するconst enumの定義元の情報が参照できない状況下では、const enumの本来の挙動であるインライン化は実現できません。

注目すべきは、b.jsvar A = require('./a').A;のようなコードが含まれないことです。これはさすがにバグっぽいので、そのうち治るのかもしれませんが、少なくともTypeScript 3.8.2現在修正されていません。

3. 数値のenumは型安全ではない

数値のenumにはあらゆるnumberを割り当て可能です。

enum ZeroOrOne {
Zero = 0,
One = 1
}
const zeroOrOne: ZeroOrOne = 2; // no error!!

これについてもTypeScriptチームのメンバーの言葉を借りれば、これはビットフラグの値をenumに使うようなユースケースに対応するための“unfortunate behavior”であり、型安全性が必要な場合はunion型を使うべきであると述べられています。

4. 文字列のenumの宣言は冗長になることがある

こんなenumを書いてうんざりした経験はないでしょうか。

enum Day {
Sunday = 'Sunday',
Monday = 'Monday',
Tuesday = 'Tuesday',
Wednesday = 'Wednesday',
Thursday = 'Thursday',
Friday = 'Friday',
Saturday = 'Saturday'
}

番外. union型の方がカッコイイ

これはいくらか感情的な話なので、無視していただいても構いません。

先に示したas constを使ってunion型でenumを置き換える書き方は、TypeScriptの柔軟性と表現力の豊さを示す優れた実例です。ネイティブな列挙型のサポートがある言語出身のプログラマーがTypeScriptを使うことになったら、enumを使うことは極めて自然な選択に感じられるかもしれません。しかし、as constを使ったunion型の書き方は、そんなプログラマーに「これがTypeScriptの型システムだ!」と言ってドヤ顔をするのに格好のサンプルです。

このようなパターンを積極的に採用することで、TypeScriptへの理解をより深められ、結果としてよりよいTypeScriptプログラマーになれるでしょう。

enumをコードから排除する方法

typescript-eslintリポジトリのissueのコメントにあるとおり、no-restricted-syntaxというESLintのルールを使うとよいでしょう。

{
"rules": {
"no-restricted-syntax": [
"error",
{
"selector": "TSEnumDeclaration",
"message": "Don't declare enums"
}
]
}
}

え?TSLintではどうするのかって?まだTSLint使ってるんですか??

まとめ

enumはunion型では実現できない特徴(「不透明性」)を持つものの、その使用に伴うデメリットが大きく、使用すべきでないと考えます。使用を一律禁止することで、個別のケースでenumを使うかunion型を使うかで悩んだり、議論したりする必要もなくなります。ESLintでenumの使用を禁止するには、no-restricted-syntaxルールを用います。

おまけ(宣伝)

3月16日にオライリー・ジャパンから出版される、『プログラミングTypeScript』の監訳を務めさせていただきました。

プログラミングTypeScript

本書では、TypeScriptについて基本的な知識を一通り学んだ上で、その高度な型システムをどのように現実のコードに適用できるかを、多くの実践的なサンプルから身に付けられます。また、JavaScriptからTypeScriptへの移行など、実際のプロジェクトでTypeScriptをどのように導入し、運用するかについての知見も得られます。TypeScriptをこれから学ぶ方におすすめなのはもちろん、すでにTypeScriptの経験がある方が、より自信を持ってTypeScriptを書けるようになるのにも役立つはずです。特に6章の「高度な型」は、熟練のTypeScriptプログラマーにも読み応えのある内容になっていると思います。

また、原著ではTypeScriptのリンターとしてすでに非推奨になったTSLintが紹介されているのですが、日本語版付録としてESLintでTypeScriptをリントする方法の説明を追加しました。この付録には、ASTを操作してESLintのTypeScript向けカスタムルールを実装する方法も記述しています。

ちなみにこの本でも、「enumの安全な使用には落とし穴が伴うため、使用は控えることをお勧めします」という内容が記述されています。

どうぞよろしくお願いいたします。

その他の記事

Other Articles

2022/06/03
拡張子に Web アプリを関連付ける File Handling API の使い方

2022/03/22
<selectmenu> タグできる子; <select> に代わるカスタマイズ可能なドロップダウンリスト

2022/03/02
Java 15 のテキストブロックを横目に C# 11 の生文字列リテラルを眺めて ECMAScript String dedent プロポーザルを想う

2021/10/13
Angularによる開発をできるだけ型安全にするためのKabukuでの取り組み

2021/09/30
さようなら、Node.js

→
←

関連職種

Recruit

→
←

お客様のご要望に「Kabuku」はお応えいたします。
ぜひお気軽にご相談ください。

お電話でも受け付けております
03-6380-2750
営業時間:09:30~18:00
※土日祝は除く