世界がEnumから隠した秘密をひとつひとつ見つけていこうな #TypeScript

はてなエンジニア Advent Calendar 2017 5日目の記事です。
前回はid:chris4403によるAmazon Pollyを試してみた - memo logでした。いやぁ社長さすがです!!

さて今日はEnumについての話題です。
TypeScriptは実行時に影響を及ぼすような独自機能は積極的に導入しない傾向にありますが、ぱっと思いつく例外がEnumとNamespace(旧Internal module)です。
今日はそのEnumについての話です。

Enumのコンパイル結果

TypeScriptを書く上でJavaScriptへのコンパイル結果を意識することは少ないと思いますが、Enumについてはコンパイル結果を知っていたほうが理解が早いと思われます。
例えば以下のコードは
enum IdolAttr {
    cute,
    cool,
    passion,
}
以下のようにコンパイルされます。
var IdolAttr;
(function (IdolAttr) {
    IdolAttr[IdolAttr["cute"] = 0] = "cute";
    IdolAttr[IdolAttr["cool"] = 1] = "cool";
    IdolAttr[IdolAttr["passion"] = 2] = "passion";
})(IdolAttr || (IdolAttr = {}));
つまり
{
    0: "cute",
    1: "cool",
    2: "passion",
    cute: 0,
    cool: 1,
    passion: 2
}
のようなオブジェクトが生成されます。
この結果を見ればIdolAttr.cuteで列挙子(キー)の値が、 IdolAttr[IdolAttr.cute]で列挙子の文字列が得られるのも納得するかと思います。
また単純なfor...in...文では値の一覧を得ることができないことがわかると思います。
キーでfor文を回したい場合は以下のようにする必要があります。
for (const i in IdolAttr) {
    if (isNaN(Number(i))) {
        console.log(i);
    }
}
これは個人の意見ですが、そもそもforさせる気がないコンパイル結果なので、forさせたくなる場面があるならEnum使うべきではないと思います。

Enumの型解決

let attr1: IdolAttr = IdolAttr.cute;
当たり前ですが、これは許されます。
またnumber型はEnum型に代入可能です。つまり
let attr2: IdolAttr = 0;
は可能です。number型であれば値にかかわらず代入可能なので、勝手な値を代入できます。
let attr3: IdolAttr = 876;
console.log(IdolAttr[attr3]) // => 実行時にundefined
あまりうれしくない仕様だと思いますがこういうものです。
加えて一つ重要な性質ですがnumber型は代入可能ですが、同じ構造でも他のEnum型は代入不可能です。
enum IdolAttr {
    cute,
    cool,
    passion,
}

enum AltIdolAttr {
    cute,
    cool,
    passion,
}

let attr4: IdolAttr = AltIdolAttr.cute;
// Type 'AltIdolAttr.cute' is not assignable to type 'IdolAttr'.

const Enum

const EnumとはEnum宣言時にconstをつけたものです。
これもコンパイル結果を見たほうが挙動がわかりやすいでしょう。
const enum IdolAttr {
    cute,
    cool,
    passion,
}

let attr: IdolAttr = IdolAttr.cool
var attr = 1 /* cool */;
にコンパイルされます。
もはやIdolAttrという変数は存在せず、すべて値がインラインに展開されます。
ですから実行時には列挙子の名前の情報は消滅する(まぁコメントとして出力されてますが)ので使用できませんし、Enum自体の利用もできません。
let attrName = IdolAttr[IdolAttr.cool];
// A const enum member can only be accessed using a string literal.
let altAttr = IdolAttr;
// 'const' enums can only be used in property or index access expressions or the right hand side of an import declaration or export assignment.
ある属性の定数値をまとめて宣言できるくらいの役割を提供してくれる機能です。
こちらの型解決についても普通のEnumと同じようにnumber型は代入可能なので意図しない値を防ぐことはできません。
let attr: IdolAttr = 961; // コンパイルが通る
ちなみに最近のTypeScriptのconst定数宣言はリテラル型になるので、constで定数を宣言しそのUnion typeを取れば意図しない値をはじくことができます。
const CUTE = 0;
const COOL = 1;
const PASSION = 2;

type IdolAttr = typeof CUTE | typeof COOL | typeof PASSION;

let attr: IdolAttr = 765;
// Type '765' is not assignable to type 'IdolAttr'.
なんだがEnumが微妙に感じられてきたんじゃないでしょうか?

文字列値のEnum

最近のEnumは値に文字列を指定できます。
enum IdolAttr {
    cute = 'cute',
    cool = 'cool',
    passion = 'passion',
}
数値が文字列になっただけと考えがちですが、数値の場合と比べて型解決に差があります。数値の場合ならばできたことがコンパイルエラーになります。
例えば以下のような感じです。
enum IdolAttr {
    cute = 'cute',
    cool = 'cool',
    passion = 'passion',
}

let attr1: IdolAttr = IdolAttr.cool;
// 当たり前だがこれはいける

let attr2: IdolAttr = 'mysterious';
// 値を直接代入することはできない
// Type '"mysterious"' is not assignable to type 'IdolAttr'.

let attr3: IdolAttr = 'cute';
// 定義されていてもだめ
// Type '"cute"' is not assignable to type 'IdolAttr'.

let attr4 = IdolAttr[0]
// indexでのアクセスは可能だがattr4ははstring型になるなる
これでEnum宣言時に存在しなかった値がEnum型として代入されることはなくなります。
またコンパイル結果は以下のようになり値=>列挙子へのマップは存在しません。(今は値と列挙子を同名にしてるのでわかりにくいですが)
var IdolAttr;
(function (IdolAttr) {
    IdolAttr["cute"] = "cute";
    IdolAttr["cool"] = "cool";
    IdolAttr["passion"] = "passion";
})(IdolAttr || (IdolAttr = {}));
したがってfor文使いたいときも素直にかけます。
for (let i in IdolAttr) {
    console.log(i)
}
// cute
// cool
// passion
なので、特に理由がなければ文字列値のEnumを使用することをおすすめします。
ちなみに文字列と数値を混ぜることも可能ですが複雑なのでここでは解説しませんし、おすすめしません。
const をつければ数値の場合と同じくインラインに展開されます。

ここまでのまとめ

  • (普通の)Enum型はnumber型を代入可能なので意図しない値を突っ込めるよ
  • TypeScriptのEnumはfor文で回しにくい構造なので列挙したくなったら要注意だよ
  • 値が文字列のEnumは数値のEnumが持つ↑2つの問題がなく使い勝手が良いのでおすすめだよ
  • const つけるとインラインに展開されるよ

Namespace を使ったstatic メソッドの定義

ここからは応用と言うか与太話に近いものです。プロダクトで使う場合は熟慮お願いします。僕なら使いません

static メソッドというのかはおいといてEnumに紐付いたメソッドを定義したいときがあると思います。 つまりIdolAttr.hoge()でアクセスできるプロパティを定義したいと言うシチュエーションです・
TypeScriptにはNamespaceというもう殆ど使われなくなりつつある機能がありまして、オブジェクトの構造を定義(型情報とJSコード)することができます。
enum IdolAttr {
    cute,
    cool,
    passion,
}

namespace IdolAttr {

    export function isCute(attr: number) {
        return attr === IdolAttr.cute
    }
}

let attr = 2;
let isCute = IdolAttr.isCute(attr);
これでIdolAttr以下にisCute()メソッドを定義できました。
IdolAttrを単なるオブジェクトに見立てプロパティを無理やり定義していくことも可能ですが型定義まではしてくれないのでEnumにどうしてもプロパティを生やしたい場合はnamespaceを使うほうが使い勝手がいいでしょう。

Nominal typeの実現

TypeScriptは構造さえ同じならば代入可能なSubstructural typeを採用しています。型の名前が違っていても構造が一緒ならば代入可能です。
JavaScriptとの互換性を考慮すれば理にかなっているように思えますが不便な場面も出てきます。
たとえばID型みたいなものを定義して他の文字列と区別するみたいなことは普通はできません。
......できないと思われがちですが、Enum型とのIntersection type、User-Defined Type Guardあたりの機能を使うとある程度Nominal typeが実現できます。
const enum Hatena {}

// Intersection type
type hatenaID = string & Hatena;

// User-Defined Type Guard
function varidateHatenaID(id: string): id is hatenaID {
    return (/^[a-zA-Z][a-zA-Z0-9_-]{1,30}[a-zA-Z0-9]$/).test(id);
}

function toHatenaID(id:string): hatenaID {
    if (varidateHatenaID(id)) {
        return id;
    } else {
        throw new Error();
    }
}

let id1 = toHatenaID('t_kyt'); // 例外処理は省く

let id2: hatenaID = "t_kyt";
// Type '"t_kyt"' is not assignable to type 'hatenaID'.
//   Type '"t_kyt"' is not assignable to type 'Hatena'.
面白いですね。
以下のような名前だけ違う型を定義してもちゃんと弾いてくれます。
const enum Hatena {}
const enum Hatena2 {}

type hatenaID = string & Hatena;
type hatenaID2 = string & Hatena2;


let id1: hatenaID;
let id2: hatenaID2;

id1 = id2;
// Type 'hatenaID2' is not assignable to type 'hatenaID'.
//  Type 'hatenaID2' is not assignable to type 'Hatena'.
これを使えばある書式の日付だとか特定の正規表現だけ許すIDの型が定義できるようになりとても便利です。
欠点は以下です。
  • number 型とのintersectionの場合使えない
    • number型がEnum型にcompatibleなのでだめだと思われる
  • どこまでtscの意図した動作かわからない
後者に関してはスペック見ればええやろと思われるかもしれませんがTypeScripのスペックは結構前から更新されておらず、信頼できません。
もっと信頼できる方法でNominal typingを実現したいという方には他の方法をおすすめします。Interfaceに隠しプロパティを設定する方法はSubstructural typeに則っていますし、TypeScript本体でも使われているのでおすすめです。
またNominal typeの導入の検討はロードマップに入っているのでその結論を待つのがもっとも安全だと思われます。まぁ導入しないという結論になるかもしれませんが。Support some non-structural (nominal) type matching · Issue #202 · Microsoft/TypeScript · GitHub

おわり

今日はTypeScriptの中でも影の薄い機能(だと僕は思っている)であるEnumについて語りました。
仕様上落とし穴が多いので値が数値のEnumを使う場合は要注意です。文字列の方は安全に使えるかと思います。