はじめに
TypeScriptで列挙型のようなものを扱うとき、どのような方法で行っていますか。enum、オブジェクトリテラル、ユニオン型などを使って書くことが考えられると思います。この記事ではそれらの記法と特徴の紹介、筆者のおすすめの列挙型を紹介します。
この記事においては列挙型はenumのことを指すのではなく、定数をまとめるシステムのことを指します。
enum
enumはJavaScriptにはないですが、TypeScriptに実装されている機能です。
説明
enumの持つ機能について簡単に説明します。
定義
一番簡単な例では以下のように書きます。
enum Mammal {
Human,
Monkey,
Lion,
Bear,
};
Mammal.Human
にアクセスすると0
、Mammal.Monkey
にアクセスすると1
のように定義した順番に数値が割り振られます。Mammal
自体を出力すると以下のようなオブジェクトが得られます。
{
"0": "Human",
"1": "Monkey",
"2": "Lion",
"3": "Bear",
"Human": 0,
"Monkey": 1,
"Lion": 2,
"Bear": 3
}
つまりMammal
は定義したプロパティからアクセスする他に、プロパティに割り当てられた数値からアクセスすることができます。つまりMammal[Mammal.Human]
にアクセスするとHuman
が出力されます。
割り当てる値
割り振られる数値は代入することができます。
enum Mammal {
Human,
Monkey = 11,
Lion,
Bear,
};
この場合はMammal.Human
にアクセスすると0
、Mammal.Monkey
にアクセスすると11
、Mammal.Lion
にアクセスすると12
のように指定する箇所までは通常通り、指定したところからはその数字から順番に割り振られていきます。
enum Mammal {
Human,
Monkey = 11,
Lion = 10,
Bear,
};
このように定義すると、Mammal.Monkey
とMammal.Bear
が同じ数値となってしまいバグの原因となるので特別な事情がない限りはそのまま使います。この状態でMammal
を出力すると下のように11
からMonkey
にアクセスできなくなります。
{
"0": "Human",
"10": "Lion",
"11": "Bear",
"Human": 0,
"Monkey": 11,
"Lion": 10,
"Bear": 11
}
割り振る値は数値だけではありません。数値以外を割り振る場合はすべてのプロパティに割り振る必要があります。
enum Mammal {
Human = 'h',
Monkey = 'm',
Lion = 'l',
Bear = 'b',
};
この場合は割り振られた値からプロパティにアクセスできないことに注意して下さい。出力が以下のように列挙されるだけだからです。
{
"Human": "h",
"Monkey": "m",
"Lion": "l",
"Bear": "b"
}
拡張
enumは同じ名前で定義することで拡張できます。
enum Mammal {
Human = 'h',
Monkey = 'm',
Lion = 'l',
Bear = 'b',
};
enum Mammal {
Dog = 'd',
};
この例は下の定義と同じになります。
enum Mammal {
Human = 'h',
Monkey = 'm',
Lion = 'l',
Bear = 'b',
Dog = 'd',
};
下のようにすると割り振られる数値0
が被るという理由でエラーが起きます。しかし、Dog = 0
とすれば避けられるのであまり強い制約にはなっておりません。
enum Mammal {
Human,
Monkey,
Lion,
Bear,
};
enum Mammal {
Dog,
};
const enum
拡張できないenumとしてconst enumがあります。enumのように拡張できないのに加えて、割り振られた数値からアクセスできないです。
const enum Mammal {
Human,
Monkey,
Lion,
Bear,
};
// enumで利用できた下の二つはconst enumではエラーになる
enum Human {
Dog = 10000,
};
console.log(Mammal[Mammal.Human])
enumではjsで使用するために以下のようなコードを生成する必要がありました。
var Mammal;
(function (Mammal) {
Mammal[Mammal["Human"] = 0] = "Human";
Mammal[Mammal["Monkey"] = 1] = "Monkey";
Mammal[Mammal["Lion"] = 2] = "Lion";
Mammal[Mammal["Bear"] = 3] = "Bear";
})(Mammal || (Mammal = {}));
const enumではこのようなコードは生成せずに値がそのまま割り当てられます。const a = Mammal.Human
というTypeScriptのコードはenumでは先ほど紹介たコードを利用してvar a = Mammal.Human
のようになりますが、const enumではvar a = 0
のように値をそのまま割り当てるのでパフォーマンスの点で利点があります。
問題点
基本のenumでは型が弱い
enumで定義した値を利用したい場合は以下のように行います。
enum Mammal {
Human,
Monkey,
Lion,
Bear,
};
let mammal: Mammal = Mammal.Human;
これでmammal
の型はMammal
となりました。しかし、mammal = 10000
のように記述しても型エラーが起きません。つまり、せっかくenumを定義してもnumberと同じレベルの型ができてしまってることが原因です。これは数値を自動に割り当てるのではなく、一つ一つに割り当てたとしても変わりません。この問題はconst enumでも同様の問題があります。
しかし、文字列を割り当てた場合はそのようなことは起きません。
enum Mammal {
Human = 'h',
Monkey = 'm',
Lion = 'l',
Bear = 'b',
};
let human: Mammal = Mammal.Human;
としたときにhuman = 'abc'
のようするとちゃんとエラーが発生します。human = 'h'
のようにしてもエラーが発生し、Mammal.Human
つまりMammal
を代入しなければエラーが起きることに注意して下さい。文字列の動作自体は悪くない(むしろ良い)と考えているのですが、数値と文字列で挙動が異なることを考えると不便に感じます。
拡張後のプロパティが拡張前から型として存在する
本来下のようなコードはMammal
に存在しないDog
にアクセスしているのでエラーが発生します。
enum Mammal {
Human = 'h',
Monkey = 'm',
Lion = 'l',
Bear = 'b',
};
console.log(Mammal.Dog);
しかし、以下の例ではエラーが発生せずに、undefined
が出力されます。
enum Mammal {
Human = 'h',
Monkey = 'm',
Lion = 'l',
Bear = 'b',
};
type IsString<T> = T extends string ? true : false;
let isString: IsString<Mammal.Dog>
console.log(Mammal.Dog);
enum Mammal {
Dog = 'd',
};
isString
の型を確認するとtrue
となっていて型的にはstring
に含まれる型(ここでは'd'です)と推論されているようですが、実際の出力はundefinedとなり矛盾します。このように拡張することによって型と値で矛盾が起きることがあります。この挙動はバグの元になるので拡張をしないというルールかconst enumだけを使うように矯正することが必要となります。
その他の問題
enumとconst enumにはTypeScriptの設定によってはコンパイルができなかったり、Tree Shakingのパフォーマンスが低下するなどの問題を孕んでいます。
これらの問題も重大な問題と考えていますが、この記事では型の安全性と使いやすさの視点を大事にしたかったので詳細は省略します。
感想
enumは仕様が複雑ですし、実装の上で気をつけなければいけない点が多いので列挙型としては積極的に利用したい機能ではないと考えています。JavaScriptにはない機能ということもあってあまり使われていない印象です。
オブジェクトリテラル
先ほど記述したようにTypeScriptにおけるenumにはさまざまな問題点があることからオブジェクトリテラルを用いて列挙型を構築することが多いです。
説明
オブジェクトリテラルを列挙型として扱う方法を説明します。
定義
オブジェクトリテラルを使った列挙型は以下のように書きます。
const Mammal = {
Human: 0,
Monkey: 1,
Lion: 2,
Bear: 3,
} as const;
ただのオブジェクトをas const
しただけです。as const
とすることでそのオブジェクトのプロパティが全てreadonly
になります。ネストされたオブジェクトのプロパティも全てreadonly
になる便利な機能です。as const
はas
を使っているので型アサーションのように型の安全性を脅かしそうですが、むしろ型を強力にする機能なので安心して使うことができます。ここではMammal
のプロパティの書き換えを防ぐ役割を果たしています。また、as const
にすることで値がnumberのような抽象的な型ではなく0
や1
のように扱ってくれるので型を正確に持つことができます。
使用方法
Mammal
はMammal.Human
のようにアクセスして使います。当然ですがenumの時のようにMammal[Mammal.Human]
のような呼び出しはできないので注意して下さい。
Mammal
のプロパティアクセスによって呼び出される型は少し長くなりますがこのように書きます。
type Mammal = typeof Mammal[keyof typeof Mammal];
Mammal
自身の型にMammal
のプロパティでアクセスするような型となっています。この型は0 | 1 | 2 | 3
となり、Mammal
の値を正確に求めてくれます。as const
がなければnumber
になってしまうので忘れないようにして下さい。この型を利用してMammal
の値しか引数に渡せないような関数を作成することができます。
const isHuman = (mammal: Mammal): boolean => {
if (mammal === Mammal.Human) {
return true;
}
return false;
};
引数の方はあくまで0 | 1 | 2 | 3
なのでMammal.Human
ではなく0
を直接代入できることに注意して下さい。列挙型をオブジェクトリテラルで扱うときに毎回この型を書くのはめんどくさいので汎用型を予め定義しておくと楽です。
type ObjectValueList<T extends Record<any, any>> = T[keyof T];
type Mammal = ObjectValueList<typeof Mammal>
問題点
大きな問題点はないですが、使っていて気になる点を紹介します。
オブジェクトの拡張
オブジェクトは定数として定義したとしてもObject.assign
によって追加することができます。
const Mammal = {
Human: 0,
Monkey: 1,
Lion: 2,
Bear: 3,
} as const;
Object.assign(Mammal,{ Dog: 4 });
これによってMammal
はDog
をプロパティにもつオブジェクトになりますが、型を確認するとDog
をプロパティに持たない追加する前の型のままになってます。これによりMammal
からDog
にアクセスすることはできませんし、Mammal
自体を出力しない限りDog
がコードに現れることはないです。列挙型として扱う分にはMammal
自体を代入や出力することはほとんどないので問題ないですが、値として持っているのは気持ち悪いのでObject.assginを使わないようすることや、Mammal
自体の操作を行わないように心がけたいです。
複数のimport
値であるオブジェクトプロパティとその型の両方を使う側でimportする必要があるのでコードが嵩みます。オブジェクトリテラル自体に大きな変更を加えたときに考慮すべき箇所が倍になります。
値のまま入力できる
オブジェクトリテラルによって値を定義してそこから型を生成しましたが、その型を割り当てた値にはオブジェクトリテラルにプロパティアクセスして得た値を入力する必要はなく値をそのまま代入することができます。これが問題かどうかについては議論が分かれるところではあると思いますが、オブジェクトを利用せずに値をそのまま代入していた場合にオブジェクトの値を変更するとその部分の変更を忘れたことによってバグに繋がる可能性があるので私としては問題点に感じています。列挙型からその値を消したときは型エラーになるので気付きませんが、順番を入れ替えた時などはバグを生んでしまいます。
まとめ
クリティカルな問題点はなく、ここで挙げた問題点も気になる程度なので列挙型としてこの記法を用いるのはおすすめです。TypeScriptが提供する機能というわけではないので、複数人で開発する場合はこの記法の紹介と列挙型にはこれを使うことを共通認識として持つようにしておくとスムーズな開発ができます。
ユニオン
ユニオン型として一覧の型を持つ方法です。
説明
他の方法とは異なり値を持たず型だけでやりくりする方法です。
定義
シンプルに下のように定義して列挙型として扱いたい値の型とします。
type Mammal = 'Human' | 'Monkey' | 'lion' | 'Bear';
提供するのは型だけで取り得る値を列挙するだけなのでとてもシンプルに書けます。さらに型は拡張ができないので型安全に書き進めることができます。
使用方法
Mammal
を持つ変数を定義するにはlet mammal: Mammal
のように使用します。
Mammal
だけを引数に取らせるような関数は下の通りです。
const isHuman = (mammal: Mammal): boolean => {
if (mammal === 'Human') {
return true;
}
return false;
};
比較には'Human'
のように直接値を入力する必要があるのがポイントです。これだと'Huma'
のように誤入力したときに意図しない動作することが懸念点として挙げられますが、列挙した型に存在しない値だと型エラーが起きるのでその心配はないです。
問題点
直接文字列を扱う必要がある
先ほど述べましたが、型として定義しただけなので文字列を直接扱わなければいけないです。これによって列挙された値に変更があった場合に全ての利用箇所で変更を加える必要があるので影響調査が大変になります。追加や削除を行なっても型エラーが出るので対処できますが、値の書き換えを行うとエラーが出ずにバグを引き起こす可能性があるので避けた方が良いです。
列挙型を用いたループができない
これまで紹介した方法は値として列挙型を持っていたので列挙型をループさせることが可能でした。例えばオブジェクトリテラルの場合は以下のようにすることで配列を取得できるのでそれを用いてループさせることができます(もちろんenum方法は違いますがループさせることができます)。
Object.values(Mammal);
// ['Human', 'Monkey', 'lion', 'Bear']
ユニオン型を用いた方法では型としてしか情報がないのでループさせることができません。下のように配列を定義してそこからユニオン型を定義することによって解決することができます。
const mammal = ['Human', 'Monkey', 'lion', 'Bear'] as const;
type Mammal = typeof mammal[number];
このように定義するとオブジェクトリテラルの代わりに配列で列挙型を作ったような見た目になります。ここまでくると直接文字列を扱わなくて済むオブジェクトリテラルで書いても良いかもしれません。
まとめ
この方法もクリティカルな問題がないです。さらにTypeScriptの機能を利用しているのでチームのメンバーが変更するたびに共有する必要などがありませんし、シンプルにわかりやすく書けます。値のまま扱わなければいけないところが懸念としてありますが、使用する範囲が限定されていれば影響範囲も特定し易いのでおすすめです。列挙型でループさせる必要があるときはオブジェクトリテラルの劣化のようになるのでこの方法はおすすめできません。
さいごに
TypeScriptを用いて列挙型を用いる方法を紹介しました。enumには問題点がたくさんあるので使うことはお勧めできませんが、オブジェクトリテラルとユニオン型を利用した方法はどちらもお勧めできる記法です。
プロジェクト全体で使うような列挙型はオブジェクトリテラル、小さな範囲だけで使うときはユニオン型のように使い分けることや、プロジェクトによって使い分けることがお勧めです。
私はオブジェクトリテラルが値を直接記入する必要がない点で長らく利用していましたが、Material UIやRecoilなどのライブラリでは値を直接入力させるようになっていることが多いので最近はユニオンも状況によって使い分けるようにしてみました。今のところどちらも一長一短で使い所によって使い分けるのがベストと考えています。