Programming Field

[TypeScript] enum 文を使わずに最適化可能な似非 Enum を作る

TypeScript には「enum 文」がありますが、これを使わずに非常に近いものを作ろうとした方法をまとめたページです。

なお、このページでは TypeScript 4.2.3 にて、ESモジュールのスタイル + moduleResolution = node で記述していくことを前提としています。

TL;DR

  • TypeScript の enum は便利だが、ECMAScript にはない独自構文であるため微妙な点がある
  • 特に const enum 文は(preserveConstEnums をしない限り)ビルド時にモジュールを超えた値の情報が必要になり、babel でトランスパイルできなくなる
  • const を使ったオブジェクト定義やESモジュールの「名前空間」機能を使うことで、似非 Enum を作ることができる(Tree-Shakingなどの最適化も働く)
  • 似非 Enum にはそのままだと型が無いので、type 文を上手く使って宣言のマージをする必要がある(ただし方法によっては一部難がある)
  • 似非 Enum に型の厳密性を持たせたい場合は、列挙子の型を厳密にさせることで可能になる(厳密にさせるユーティリティ関数も作成可能)

概要

TypeScript の enum 文は、他の言語にあるそれと同様に、いくつかの定数をまとめる際に便利な構文です。

enum Hoge {
    Foo = 1,
    Bar = 2
}

enum の特徴としては「Hoge.Foo のように名前空間的に値を参照できる」「enum の種別名(Hoge)自体が型になっている」「各列挙子もまた型として扱える」といった点があり、いずれも型の厳密性を上げるために便利なものとなっています。

しかし、「enum」という概念は TypeScript のベースとなった ECMAScript には存在せず、TypeScript には珍しい「型の領域を超えたランタイムに直結する構文」となっています。特に const enum 文は、TypeScript コンパイラー(以下 tsc)が TS ファイルを JS ファイルに変換する際にすべての const enum 定数を実際の数値に置き換えるという処理をしており、それによって「ファイル単体でのコンパイルが不可能(他のファイルを見ないと置き換えることができない)」という弊害が生まれています。

※ そのため、babel の plugin-transform-typescript では追加プラグインなしに const enum 文を非サポートとしていたり、tsc でも「isolatedModules」を有効にしている場合は「preserveConstEnums」を無効にできないなどといったことがあります。

一方で普通の enum 文を使う、あるいは「preserveConstEnums」を使う(「isolatedModules」を使う場合は必然的に使用する)場合、tsc が出力するJSが以下のように Tree-Shaking や不要な値の削除などの最適化がしづらい形となります。

var Hoge;
(function (Hoge) {
    Hoge[Hoge["Foo"] = 1] = "Foo";
    Hoge[Hoge["Bar"] = 2] = "Bar";
})(Hoge || (Hoge = {}));

そこで、以下のような選択肢をとることが考えられます。

  1. 「isolatedModules」を使わず、「preserveConstEnums」も使わない (+ babel で babel-plugin-const-enum プラグインを利用する)
  2. 「isolatedModules」を使い、「preserveConstEnums」を許容する (+ babel で babel-plugin-const-enum プラグインを利用する)
  3. enum 以外の手段で記述する

このうち1と2については babel と tsc で出力結果が変わることとなることに注意しておく必要があります。

本ページでは3番目の手段について考えられる方法を紹介したいと思います。なお、これらの方法は ECMAScript の構文をベースとしたものであるため、babel と tsc で結果が変わることは無いとみることができます。

const オブジェクトの利用

この方法はもっとも単純な方法であり、

const Hoge = {
    Foo: 1,
    Bar: 2
} as const;

と記述します。末尾に「as const」を付けることで、FooBar がそれぞれ読み取り専用扱いになり、かつこれらの値がリテラル型(この場合はそれぞれ「1」と「2」という型)となるため、型の厳密性が増します。

このように記述すると、(見た目から明らかですが)実際に値を使用したい場合には enum と同様に Hoge.Foo などと記述することで利用が可能です。

ただし、これだけだと「オブジェクト(= 変数)」しか定義していないため、enum の特徴の1つである「型として enum を利用する」ことができません。

そこで、以下のように「型」を定義します。

type Hoge = typeof Hoge[keyof typeof Hoge];

ここで注意すべきは、type キーワードの直後にある Hoge と、typeof キーワードの直後にある Hoge は別の識別子となっているという点です。TypeScript では識別子について「名前空間」「型」「値(変数)」の3種類の宣言グループがあり、グループが異なる場合は同一の名前を使用することが可能となっています(参考: TypeScript: Documentation - Declaration Merging)。そのため、const での定義では「値」のみを定義していたところに、type での定義を使って「型」を追加定義することができ、このような文で型を与えることが可能になっています。

なお、

typeof Hoge[keyof typeof Hoge]

については、「[」文字手前の「typeof Hoge」で「{ readonly Foo: 1, readonly Bar: 2 }」という型を、「keyof typeof Hoge」で「'Foo' | 'Bar'」という型を得ており、それを「[ ]」で参照することで「1 | 2」という型を得ることができます。

※ TypeScript において、型クエリーの typeof は「[」文字よりも優先度高く評価されます。そのため、型記述においては「(typeof Hoge)[...]」と「typeof Hoge[...]」は同じ意味になります。

これにより、

const hoge: Hoge = Hoge.Foo;

という記述が可能となります。

なお最適化については、terser を利用した場合、「const XXX = { ... }」形式の列挙データに対応しているためか、XXX 自体をオブジェクトとして扱っていない限り、個別の変数に展開 → 変数の利用箇所を定数に置き換え・使われない変数(= 列挙子)は削除、という最適化を行ってくれます。(デフォルトオプションで確認)

この方法は記述しやすく可読性も高いのでほぼデメリットはありませんが、唯一挙げるとすれば、「Hoge.Foo」を型として利用することができないという点です。Declaration Merging の概念を使い、namespace キーワードを使ってさらに以下の定義をすることができますが、namespace 構文は TypeScript の独自構文であり、Linter の設定で namespace を禁止としている場合があるので注意して利用する必要があります。

declare namespace Hoge {
    export type Foo = typeof Hoge.Foo;
    export type Bar = typeof Hoge.Bar;
}

namespace キーワードにおける宣言は、中に値を含まない限り「名前空間」のみの識別子を登録することができます。例えば、上記に1つでも「export let」や「export function」などが入ると「値」の識別子も登録されることとなり、既存の定数定義(const Hoge = ...)と重複するためエラーとなります。
declare キーワードは、TypeScript においてはこの場合あっても無くても同じですが、babel に「型のみの名前空間」と認識させるためには必要となります。(参考: Impartial Namespace Support - @babel/plugin-transform-typescript · Babel)

ESモジュールの利用 (注意点あり)

もう1つの方法は、定数をまとめたESモジュールを作成する方法です。例えば、以下を「Hoge.ts」として記述します。

export const Foo = 1;
export const Bar = 2;

これを、実際に使用したい場所で

import * as Hoge from './Hoge';

とインポートすれば、前記の const 利用の場合と同様に Hoge.Foo などの形で参照することができます。

こちらの特徴は、「Hoge.ts」内に型を書くことが可能となっているという点です。モジュールファイルなので当たり前といえば当たり前ですが、例えば「Hoge.ts」で

export type Foo = typeof Foo;
export type Bar = typeof Bar;

と書けば、容易に「Hoge.Foo」が「値」であり「型」でもある識別子とすることができます。(詳細には、import 文で作った名前 Hoge は「名前空間」+「値」の識別子扱いになっており、これは const 利用の場合における namespace キーワードとの組み合わせと結果的に同じになります。)

この方法の場合、webpack や rollup などのバンドラーを利用すると、各バンドラーの最適化によって変数(事実上の列挙子)を使用している箇所が(モジュールに定義されている)元の変数への参照の書き換わり、さらに terser などで最適化をかけると変数利用箇所を定数に置き換えてくれる(& 未使用変数は消える)ので、const enum を使った場合とほぼ同等の効果を得ることができます。

では Hoge を型としても扱えるようにする場合はどうするという話になりますが、Hoge は「値」+「名前空間」であり「型」ではないので、

import * as Hoge from './Hoge';
type Hoge = typeof Hoge[keyof typeof Hoge];

とすれば良さそうに見えますが、実はこれを再エクスポートしようとすると問題になります。というのも、

export { Hoge };

とすると、(TypeScript 4.2.3 時点では) なぜか型しかエクスポートされず、それを別ファイルで使おうとすると「値」および「名前空間」として使用できなくなります。(関連 issue: Exported type merged with 'export * as namespace...' only exports type meaning. · Issue #36792 · microsoft/TypeScript)

そこで、Hacky な方法ですが、以下の2種類の方法で宣言をマージすることができます。

※ どちらの方法もデメリットがあるので、理想は上記 issue が解決して普通の方法でマージできるようになることと考えています。

1. 定数定義に JS と .d.ts ファイルを使う

1つ目の方法は、定数定義を JS ファイルで行いつつ、その型定義として .d.ts ファイルを作るという方法です。JS ファイルでの記述は(type 文を書かないという点を除いて)前述した export const の記述と変わりません。.d.ts ファイルでは「export =」文が使用できるので、「Hoge.d.ts」として

export type Foo = typeof Foo;
export type Bar = typeof Bar;
type Hoge = Foo | Bar;
export = Hoge;

と書けば、TS ファイルで「import * as Hoge from './Hoge'」と書くことで Hoge を「値」「名前空間」「型」として利用することができます。

※ 「export =」を使う場合は後続するものが識別子である必要があるため、上記では「Hoge」として書いています。
※ 「export =」を使った場合、「export * as Hoge from './Hoge'」と書くことは出来ません。一旦 import してから名前付き export することは可能であるので、分割して書けば問題ありません。

この方法のデメリットはファイルが分かれてしまうことと、個々の列挙子に対応する型定義に改めて値(リテラル型)を直接記述しているため値の変更に弱いという点があります。そのため、実用性は低いと考えています。

2. import を書くモジュールで declare module を使う

import * as XXX from ... を書きつつ type XXX = ... とすると再エクスポートする際に型しかエクスポートされない、と前述しましたが、ここで再エクスポートするモジュール名を使ってそのモジュール内で「declare module」を使うと、(なぜか)半ば強引に宣言をマージすることができます。

例えば、import 文を書いたモジュールを「index.ts」とした場合は

import * as Hoge from './Hoge';
export { Hoge };

// moduleResolution が node であれば './index' は './' でも可
declare module './index' {
    export type Hoge = typeof Hoge[keyof typeof Hoge];
}

と書くことが可能で、このモジュールを別モジュールから参照して「Hoge」をインポートすると、Hoge を「値」「名前空間」「型」として利用することができます。

この方法は型としての Hoge の定義に値・名前空間としての Hoge を再利用できるので変更にも強くなりますが、欠点としてなぜかインポートのための入力補完が効かなくなるという問題があります。具体的には、import 文が無い状態で「Hoge」とタイプした際に import 文を補完できなかったり、「index.ts」の import 文を記述した際にインポートしたい識別子の候補に「Hoge」が出てこなかったりします。

なので、この方法はかなり Hacky な方法であるとみなして使用するものと考えています。

参考: 値をユニークにする

上記の方法で定義した似非 Enum Hoge は、型として利用すると

let h: Hoge = 2;

と、実際の列挙子の生値をそのまま代入できます。

実際の const enum でも生値が数値の場合のみ代入可能なのでこれで良い場合が多いですが、もし代入できる値を列挙子に限定したい場合、列挙子の型をさらに限定することで対応が可能になります。

例えば、const を使う場合では

const Hoge = {
    Foo: 1 as 1 & { __enum: 'Hoge' },
    Bar: 2 as 2 & { __enum: 'Hoge' }
} as const;
type Hoge = typeof Hoge[keyof typeof Hoge];

と書くと、列挙子の型が限定され、それに合わせて Hoge の型も限定されることになります。これにより、

let h: Hoge = 2; // ERROR TS2322
let f: Hoge = Hoge.Foo; // OK

と、列挙子のみ代入できるようになります。

ただしこの書き方は非常に面倒です。そこで、以下のようにジェネリック関数を作ることで「ユニークな値」を書きやすくすることができます。

type UniqueType<TName extends string, TValue> = TValue & { __enum: TName };
type UniqueGenerator<TName extends string> = <TValue>(value: TValue) => UniqueType<TName, TValue>;
function toUnique<TName extends string>(): UniqueGenerator<TName> {
    return (value) => value as UniqueType<TName, typeof value>;
}

※ 値を指定する部分の型引数指定を省略できるようにするため二重の関数としています。(「Implement partial type argument inference using the _ sigil by weswigham · Pull Request #26349 · microsoft/TypeScript」が入ると単純な関数にすることができる見込みです。)

この関数は以下のように使います。

const uniqueHoge = toUnique<'Hoge'>();
const Hoge = {
    Foo: uniqueHoge(1),
    Bar: uniqueHoge(2)
} as const;
type Hoge = typeof Hoge[keyof typeof Hoge];

なお、const を使う方法で toUnique を使う場合、変数をすべてインライン展開させるためには terser による最適化時に「compress.passes」オプションを最低「3」以上にする必要があります。

※ ES Module を使う方法では passes は「2」以上必要であることを確認しています。

2021/05/20 追記: なお、この方法では if 文や switch 文における型ガード(指定の値ではなかった場合に else ブロックでその値が方から除外される)が効かなくなるため、注意する必要があります。もともとの const enum の事情も考えると、積極的には使わない方が良いかもしれません。