2021年11月14日日曜日

TypeScriptのenumをいまさら語る

TypeScriptのenumについて。

あちこちで「enumはアカンからunion型を使え」と言われていますが、その理由や「本当にenumはいいところないの?」「unionはenumの移行先として適切なの?」などをいろいろ検証してみましょう。

最後に「こういう仕様ならいいのに」という妄想もあります。

enumについておさらい

いわゆる列挙型です。CやC++であるようなアレです。定数リテラルをシンボル化して、IDEの補完やら何やらに使えるアレです。

いいところ👍

  • 読みやすい
  • リファクタリングしやすい
  • const enumならゼロオーバーヘッド
  • 要素が文字列だけならObject.values(StringEnum)で要素の配列を取得できる
  • シンボルのエイリアスを定義できる

const enumは型チェックのみ行い、JSへトランスパイルした後は綺麗サッパリ消えてくれます。そのため生成されたJSを実行するときもオーバーヘッドが一切発生しません。

最後の「シンボルのエイリアス」というのは何かというと、例えば

// 数値文字列のチェックサムアルゴリズム
enum CHECKSUM_ALGORITHM {
	LUHN = "luhn",       // Luhnアルゴリズム
	CREDIT_CARD = LUHN,  // クレジットカード(Luhnアルゴリズムと同じ)

	MODULUS10_WEIGHT3_1 = "modulus10/weight3:1",  // modulus10/weight3:1
	ISBN13 = MODULUS10_WEIGHT3_1,                 // ISBN-13(modulus10/weight3:1と同じ)
	EAN = MODULUS10_WEIGHT3_1,                    // EAN code(modulus10/weight3:1と同じ)
	JAN = MODULUS10_WEIGHT3_1,                    // JAN code(modulus10/weight3:1と同じ)
}

のような書き方ができます。

わるいところ👎

  • const enumにしないとわけわからんオブジェクトができる
  • const enumはアンビエント宣言できない
  • const enumは要素をObject.values(StringEnum)で列挙できない
  • 数値型には値のチェック機構が働かない

まず作られるオブジェクトがカオスすぎる。なんでわざわざ関数かましてんだ。せめてオブジェクトにとどめてくれ。

アンビエント宣言は、*.d.tsのアレです。npmモジュールとして外部に晒すときに使うアレです。

const enumはわけわからんオブジェクトを作らずに済むのですが、*.d.tsに書けません。いえ、正確には書けるのですが、それを外部からアクセスしようとするとエラーになります。結果としてnpmモジュールとしてexportする必要のある型にはconst enumを使えません。

最後の項目はどういうことかというと、例えば以下のようなコードがエラーにならずに通ってしまいます。

enum NUMBER {
	ZERO = 0,
	ONE = 1,
	TWO = 2,
}

const num: NUMBER = 3;  // NUMBERにない値を設定できてしまう

これはどうやら意図した挙動のようで、以下のようなビット演算に使うような場合を想定しているそうです。

enum MASK {
	MASK1 = 0x01,
	MASK2 = 0x02,
	MASK3 = 0x04,
	MASK4 = 0x08,
}

const mask: MASK = MASK.MASK1 | MASK.MASK2;  // 値は0x03

unionについておさらい

union型といえば、number | stringのように「数値型または文字列型」のような型を作れるアレです。リテラル型のunionを使えばenumと実質的に同等のことができるぞ!というわけです。
type CHECKSUM_ALGORITHM = "luhn" | "modulus10/weight3:1";

const checksum1: CHECKSUM_ALGORITHM = "luhn";
const checksum2: CHECKSUM_ALGORITHM = "abc";  // エラー("luhn"または"modulus10/weight3:1"しか代入できない)

enumをunionで置き換える方法

上で挙げたサンプルコードだと、確かに値は制限されるけどシンボルで置き換えているわけじゃないし使いにくいですね。

そこで、オブジェクトとkeyof typeofを使えばenumと同じように使える型が出来上がります。

const CHECKSUM_ALGORITHM = {
	LUHN: "luhn",
	CREDIT_CARD: "luhn",

	MODULUS10_WEIGHT3_1: "modulus10/weight3:1",
	ISBN13: "modulus10/weight3:1",
	EAN: "modulus10/weight3:1",
	JAN: "modulus10/weight3:1",
} as const;
type CHECKSUM_ALGORITHM = typeof CHECKSUM_ALGORITHM[keyof typeof CHECKSUM_ALGORITHM];

// シンボルで書ける
const checksum: CHECKSUM_ALGORITHM = CHECKSUM_ALGORITHM.CREDIT_CARD;

うん、まあ書けるっちゃ書けるけど一手間多いですね。元々enumとunionは目的が違うので、どうしても列挙用途ではenumのほうがシンプルに書けるのは仕方ない。イディオムみたいなものだと思えばいいんでしょうか。

また、実際にオブジェクトが作られる分const enumよりもわずかにオーバーヘッドがあります。enumほどじゃないですが。

ただし、実際にオブジェクトが作られるのでObject.values()で要素を列挙できるという利点はあります。

enumをunionで置き換えられないケース

定義の方法がちょっとややこしいというのは置いておくとしても、それ以外にenumのほうが便利だったり単純にenumをunionで置き換えられないケースというものも存在します。

まずは、unionではシンボルのエイリアスを定義できないという点。上のCHECKSUM_ALGORITHMの例を見てみるとわかりますが、LUHNCREDIT_CARDに同じ文字列"luhn"を割り当てているのでDRYの原則から外れてしまいます。enumではCREDIT_CARDLUHNを代入するだけで済みました。

union版は定義したシンボルはあくまでオブジェクトに過ぎません。シンボルのエイリアスが必要になる場面はあまりないかもしれませんが、いざ必要になったときに慌てないように知識として覚えておいてもいいと思います。

もう1つは、数値型の値のチェックができてしまうという点。enum版では通った以下のようなコードがエラーになってしまいます。

const MASK = {
	MASK1: 0x01,
	MASK2: 0x02,
	MASK3: 0x04,
	MASK4: 0x08,
} as const;
type MASK = typeof MASK[keyof typeof MASK];

const mask: MASK = MASK.MASK1 | MASK.MASK2;  // エラー; 0x03をMASK型に代入できない

これも必要になる場面はあまりないと思いますが。

unionはenumの移行先として適切か?

まず適切かどうかではなく使えるかどうかで判断するなら、だいたいの場合は使えます。ただし、元々の作られた目的が違うため、どうしてもunionだと冗長な書き方になってしまいます。

unionへ置き換える前に、まずはconst enumを使えないかを検討しましょう。それでも解決しない場合にunionを使いましょう。

TypeScriptチームへ期待すること

注:このセクションの内容は完全な妄想です。ここに書いてあるような文法やキーワードはTypeScriptにはありませんし、実装予定でもありません。要望も出していません。「こう使えたらいいのにな」という妄想です。

もしかしたらすでにissueが出ているかもしれませんが、unionのトリックを使わなくても済むようにenumを強化してもらえると素敵です。

具体的には

  • デフォルトでconst enum相当の動作(ゼロオーバーヘッド)
  • アンビエント宣言を外部から使える

あたりでしょうか。現在アンビエント宣言されたconst enumを外部から使えない仕様なのは何ででしたっけ。言語仕様的に何か問題あるのかな。

const enum相当だと要素を列挙できない問題については、enumの中身を反復可能なオブジェクトとして返す新しいキーワードを導入するとかどうでしょう。

enum FOO {
	Foo = "foo",
	Bar = "bar",
	Baz = "baz",
}
// enumerateキーワードで中身を列挙
for (const foo of enumerate FOO) {
	console.log(foo);
}
// こんな感じにトランスパイルされる(FOOの中身がその場で展開される)
for (const foo of ["foo", "bar", "baz"]) {
	console.log(foo);
}

数値型のアレはなぁ・・・デフォルトの挙動は今のままで仕方ないとして、列挙された値しか許可したくない場合は・・・例えばstrict enumみたいな文法はどうですかね。

// 範囲外の数値を使えないようにするstrict enum
strict enum MASK {
	MASK1 = 0x01,
	MASK2 = 0x02,
	MASK3 = 0x04,
	MASK4 = 0x08,
}
const mask: MASK = MASK.MASK1 | MASK.MASK2;  // エラー; 0x03をMASK型に代入できない

やっぱり列挙型の問題は無理にunionを使わずにenumを強化するのが一番いいと思うんだな。

0 件のコメント:

コメントを投稿