Typescript で文字列列挙型をうまく定義したい話
## TL;DR
- JavaScript 文化圏では列挙型 = 文字列が一般的である。
- TypeScript にも enum はあるが、目的のものとは異なっている。
- その手の定義を上手く書きたい。
結論↓
```ts
export type MyType = typeof _MyType;
const _MyType = '' as keyof typeof MyTypeEnum;
const MyTypeEnum = {
a: new MyClass('aValue', 0),
b: new MyClass('bValue', 1),
c: new MyClass('cValue', 2)
};
```
## 前提
JavaScript 文化圏的には、列挙値には "文字列" を用いるのが一般的である。
例:
~~~js
// 型
typeof x === "number";
// KeyboardEvent
event.code === "Enter";
// css
element.style.display = "block";
// canvas
context.lineJoin = "round";
// Web Audio API
oscillator.type = 'square';
// Media Streams API
navigator.mediaDevices.getUserMedia({
audio: true,
video: {
facingMode: "user" // これ
}
});
~~~
DOM や WebGL(OpenGL) など、外部由来のものや、
古い仕様の中には整数列挙型を用いるものもあるが、
最近の JavaScript ではライブラリ外部 ⇔ 内部の列挙型のやり取りは文字列が多い。
Web 周りのインターフェースを記述する[WebIDL](https://en.wikipedia.org/wiki/Web_IDL)
においても、基本的に列挙値は数値ではなく文字列である。
自作のライブラリを書く時も、この方針に則りたい。
## TypeScript の列挙型 (enum)
TypeScript にも元々列挙型はある。
こんな感じに書く。
~~~ts
enum MyEnum {
a, // 0
b, // 1
c = 2 // 直接指定も可能 (C 言語等と同じ)
}
let v: MyEnum = MyEnum.a;
let n: number = v; // ok, enum は 数値の部分型。
// 名前の逆引きも可能
console.log(MyEnum[MyEnum.a]); // => "a"
~~~
値は数値なので、目的のものとは異なっている。
一応、TypeScript のバージョンが上がって、
文字列列挙型も定義できるようにはなった。
~~~ts
enum MyStrEnum {
a = "a",
b = "b",
c = "c"
}
function myFunc(x: MyStrEnum) {
...
}
myFunc(MyStrEnum.a); // ok
myFunc("a"); // ok
~~~
ただ、この方法はいくつか嫌な部分がある。
- キー名 = 値にしたい場合、同じことを二度書く必要がある。
- キーのリネームはできるが、値のリネームはできない
- 数値型と異なり、逆引きができない (キー名 = 値なら不要ではあるが)
とりあえず使う分には問題ないけれど、
ライブラリを作る場合いまいち使いにくい。
## 案1 ストリングリテラル型を用いる。
TypeScript には、ストリングリテラル型がある。
~~~ts
type MyStrType = "a"; // 文字列 "a" のみを受け取る型
let x: MyStrType;
x = "a"; // ok
x = "b"; // error, "a" しか受け付けない。
~~~
これと直和型を組み合わせれば、文字列列挙型風のものが書ける。
~~~ts
type MyEnum = 'a' | 'b' | 'c'; // "a" または "b" または "c"
function myLibFunc(x: MyEnum) { ... }
~~~
かなり書きやすくはなったものの、リネームが効かない問題はいまだ残る。
また、ライブラリ内部では、受け取った文字列列挙値を使って何か処理をするわけで、
例えば以下のような、
補助オブジェクトを利用することが多い。
~~~ts
type MyEnum = 'a' | 'b' | 'c';
class MyEnumInfo { /* 各列挙値に関する情報 */ }
const MyEnumObj: Record<MyEnum, MyEnumInfo> = {
a: new MyEnumInfo(...),
b: new MyEnumInfo(...),
c: new MyEnumInfo(...),
};
function myLibFunc(x: MyEnum) {
let info = MyEnumObj[x];
// 何かする。
}
~~~
結局、列挙型と補助オブジェクトで、名前を二回書く必要が出てきてしまった。
なんとかしたい。
## 案2 補助オブジェクトから列挙型を定義する。
TypeScript では、型記述に `typeof` キーワードを用いることで、
明示されていないオブジェクトの型を得ることができる。
~~~ts
const obj = {
a: 10,
b: 10
};
type MyType = typeof obj;
// 次のように書いたのと同じ
type MyType = {
a: number;
b: number;
};
~~~
また、`keyof` を用いて、
オブジェクト型のプロパティ名から型を得ることができる。
~~~ts
type MyType = {
a: number;
b: number;
};
type MyKeyType = keyof MyType;
// 次のように書いたのと同じ
type MyKeyType = "a" | "b";
~~~
この二つを組み合わせれば、
補助オブジェクトから文字列列挙型(相当)を定義できる。
~~~ts
const MyEnum = {
a: new MyEnumInfo(...),
b: new MyEnumInfo(...),
c: new MyEnumInfo(...),
};
type MyType = keyof typeof MyEnum;
// 次のように書いたのと一緒
type MyType = "a" | "b" | "c";
~~~
これは、名前を二回書く問題をクリアしている。
また、エディタ上のリネームの問題をある程度緩和してくれる。
文字列として扱っている部分までは変えてくれないが、
プロパティ名として扱っている分には、いつも通りリネームが効く。
やったぜ。…といいたいところだけれどまだ問題が残っている。
## typeof を用いた場合の定義ファイルの問題
`typeof` を用いた場合、元となるオブジェクトの型情報も型定義ファイルに残ってしまう。
例えば以下のように書いたとする。
~~~ts
export type MyType = keyof typeof MyTypeEnum;
class MyClass { // 中身は適当
name: string;
value: number;
constructor(name: string, value: number) {
this.name = name;
this.value = value;
}
method() {
console.log(this.name, this.value);
}
}
const MyTypeEnum = {
a: new MyClass('aValue', 0),
b: new MyClass('bValue', 1),
c: new MyClass('cValue', 1),
};
~~~
理想は、型定義ファイルはこうなってほしい
~~~ts
declare module "モジュール名" {
export type MyType = "a" | "b" | "c";
}
~~~
現実には、ここから得られる型定義ファイルは以下のようになる
~~~ts
declare module "モジュール名" {
export type MyType = keyof typeof MyTypeEnum;
class MyClass {
name: string;
value: number;
constructor(name: string, value: number);
method(): void;
}
const MyTypeEnum: {
a: MyClass;
b: MyClass;
c: MyClass;
};
}
~~~
`export` されているのは、`MyType` のみなので、
本来その情報だけ含まれていれば十分なのだけれど、
芋づる式に、内部で使用されているだけの `MyTypeEnum` の値の型、
さらにはそこで使用されている `MyClass` の中身までが
型定義ファイルに含まれてしまっている。
`MyClass` が他のローカルな型を使用していれば、
それも型定義ファイルにあふれ出すことになる。
`export` されてないものは、どうせライブラリ外から触れない。
なので、型レベルでの問題はないのだけれど、
無用に内部構造をさらしているのは
正直なところあまりうれしくない。
C言語で言えば、ヘッダに使ってもいない構造体がやたらと定義されているようなもので、
ファイルサイズは増えるし、ノイズが増えて読みにくくなるし、いいところが無い。
どうにかマシにしなければ。
## 解決案
と、いうわけでひねりだしたのがこちら。
~~~ts
export type MyType = typeof _MyType;
const _MyType = '' as keyof typeof MyTypeEnum; // (*)
class MyClass { /* 略 */ }
const MyTypeEnum = {
a: new MyClass('aValue', 0),
b: new MyClass('bValue', 1),
c: new MyClass('cValue', 2)
};
~~~
ポイントは `(*)` の部分。
ここでは、
- `_MyType` という空文字列を持つ変数を定義して、
- `keyof typeof MyTypeEnum` に強引に型変換している。
`_MyType` というダミー変数を経由することで、
芋づる式に内部の型が漏れることを防いでいる。
型定義ファイルは以下のようになる。
~~~ts
declare module "モジュール名" {
export type MyType = typeof _MyType;
const _MyType: "a" | "b" | "c";
}
~~~
`_MyType` という名前が残ってしまっているので完全ではないけれど、
`MyTypeEnum` も `MyClass` も、外に漏れない。
また、変数 `_MyType` は、JavaScript に変換したタイミングでは、
無駄に残ってしまうのだけれど、
実体が `export` されない未使用変数なので、
一般的なモジュールバンドラー (webpack や parcel) は、
release 用の設定なら、minify のタイミングでコードを消してくれるので、
実害は基本的に無い。
めでたしめでたし。
(まあ、他の記法思いついたらまた再検討始めそうだけれど...。)