導入
TypeScriptでenum(列挙型)を検索すると、非推奨の文字がちらつくかと思います。
実際にいつもお世話になっているサバイバルTypeScriptでは以下の様に記載されています。
列挙型はTypeScript独自すぎる
TypeScriptの列挙型に目を向けると、構文もJavaScriptに無いものであるだけでなく、コンパイル後の列挙型はJavaScriptのオブジェクトに変化したりと、型の世界の拡張からはみ出している独自機能になっています。TypeScriptプログラマーの中には、この点が受け入れられない人もいます。
数値列挙型には型安全上の問題がある
数値列挙型は、number型なら何でも代入できるという型安全上の問題点があります。次の例は、値が0と1のメンバーだけからなる列挙型ですが、実際にはそれ以外の数値を代入できてしまいます。
上記の通り、Enum
は型安全上の観点から、積極的に利用しないほうが良さそうです。
そこで、代替案としてユニオン型とオブジェクトリテラルを使う方法が、しばしば挙げられています。
例えば、Enumを以下のように定義したとします。
↓↓↓
enum FruitEnum {
Apple = "りんご",
Banana = "ばなな",
}
const fruit1: FruitEnum = FruitEnum.Apple // コンパイルが通る
const fruit2: FruitEnum = "スターフルーツ" // エラーが出ます
上記Enumをユニオン型として定義すると、以下のようになります。
例えば、Enumの値に意味がない場合(ただの単語)に、こちらの記載がよく使われている印象です。
↓↓↓
type Fruits = "Apple" | "Banana"
const toJapanese(fruit: Fruits) {
switch (fruit) {
case "Apple":
return "りんご"
case "Banana":
return "ばなな"
}
}
同様にオブジェクトリテラルで定義した場合は以下のようになります。
こちらはEnumの値に意味がある場合に使われている印象です。
(調べた限りだとそんなニュアンスでしたが、厳密な使い分けはまだ理解できていません。。)
↓↓↓
const Fruits = {
Apple: "りんご",
Banana: "ばなな",
} as const
type Fruit = (typeof Fruits)[keyof typeof Fruits]
// ↑↑↑ type Fruit = "りんご" | "ばなな" ↑↑↑
const toJapanese(fruit: Fruits) {
switch (fruit) {
case Fruit.Apple:
return "りんご"
case Fruit.Banana:
return "ばなな"
}
}
しかし、オブジェクトリテラルをEnumの代替として使用する場合、毎回毎回以下のように定義してユニオン型を取得する必要が出てきてしまいます。
type Fruit = (typeof Fruits)[keyof typeof Fruits]
「これどうにかならないかな〜?」と思って、本題のオブジェクトリテラルからUnion型を取得できるジェネリック型を作ってみました。
(導入部分が長くてすみません。。)
結論
以下のようにジェネリック型(ObjectLiteralToUnion)として定義してあげることで、柔軟性・再利用性に富む共通関数っぽいものが作れます。
const Fruits = {
Apple: "りんご",
Banana: "ばなな",
} as const
type ObjectLiteralToUnion<T> = T extends { [K in keyof T]: infer U } ? U : never
type Fruits = ObjectLiteralToUnion<typeof Fruits>
// type Fruit = "りんご" | "ばなな"
また、型引数がシンプルなオブジェクトの場合には、以下のようにしても期待している動作になります。
type SimpleToUnion<T> = T[keyof T]
type Fruits = SimpleToUnion<typeof Fruits>
// type Fruit = "りんご" | "ばなな"
解説
まずはObjectLiteralToUnion
を分解してみましょう。
1. 型引数T
の導入
T
は任意のオブジェクト型を表しています。
type ObjectLiteralToUnion<T> = ...
2. 条件型(extends
)
型引数T
が特定の形 ({ [K in keyof T]: infer U }) にマッチするかどうかを確認します。
T extends { [K in keyof T]: infer U } ? U : never
3. キーのループ ([K in keyof T]:
)
keyof T
は型引数T
に含まれているキーをユニオン型として保持しています。
また、[K in keyof T]
は、そのキー内でループし、各キーに対するプロパティの値を保持します。
4. 型推論 (infer U
)
③で定義した型K
に対して、infer U
で各プロパティの型をU
として型推論します。
infer
についてはこちらがわかりやすかったので参考までに。
5. 条件評価の結果
型引数T
が{ [K in keyof T]: infer U }
の形にマッチした場合、U
(Fruitsでいうところのりんご
やばなな
)を返します。
マッチしない場合はnever
を返します。
以上によって、各プロパティの値を持つUnion型を取得できるというわけです。
もっと簡潔な共通のジェネリクス型はないの?
実はあります。
導入や結論の部分でもちょくちょく出ていますが、
type Fruit = (typeof Fruits)[keyof typeof Fruits]
これを応用して汎用的なジェネリクス型として定義した場合、以下のようになります。
とてもシンプルですね!
type SimpleToUnion<T> = T[keyof T]
型引数T
がシンプルなオブジェクト(プロパティがstring
だけのオブジェクトなど)では使えそうですね!
しかし、ちょっと困ったことが起きてしまいます。
例えば、オブジェクトT
が複雑な型(ネストされた型やその他の型の組み合わせ)を含む場合、対応できないことがあります。
以下のようにオブジェクトがネストした場合ですと、2つのオブジェクト型(Citrus, Berry)を持つUnion型が返却されてしまいます。
const NestedFruits = {
Citrus: {
Orange: "オレンジ",
Lemon: "レモン",
},
Berry: {
Strawberry: "いちご",
Blueberry: "ブルーベリー",
},
} as const
type SimpleToUnion<T> = T[keyof T]
type NestedFruit = SimpleToUnion<typeof NestedFruits>
// type NestedFruit = { Orange: "オレンジ"; Lemon: "レモン"; } | { Strawberry: "いちご"; Blueberry: "ブルーベリー" }
対して、ObjectLiteralToUnion
で対応するとどうでしょうか?
型引数のところでループをやっているだけなのですが、型引数にいれる型を柔軟に対応できるかと思います。
const NestedFruits = {
Citrus: {
Orange: "オレンジ",
Lemon: "レモン",
},
Berry: {
Strawberry: "いちご",
Blueberry: "ブルーベリー",
},
} as const
type ObjectLiteralToUnion<T> = T extends { [K in keyof T]: infer U } ? U : never
// ネストされたオブジェクト全体のプロパティ値を抽出
type NestedFruit = ObjectLiteralToUnion<{
[K in keyof typeof NestedFruits]: ObjectLiteralToUnion<typeof NestedFruits[K]>;
}>
// type NestedFruit = "オレンジ" | "レモン" | "いちご" | "ブルーベリー"
まとめ
以上のように、オブジェクトリテラルからUnion型を変換する場合は「型引数が単純かどうか?」で使い分けて使用すると良さそうです。
型引数がシンプルなオブジェクトの場合
type SimpleToUnion<T> = T[keyof T]
型引数が複雑なオブジェクトの場合
type ObjectLiteralToUnion<T> = T extends { [K in keyof T]: infer U } ? U : never
長々と書きましたが、オブジェクトリテラルからUnion型に変換したい場面は往々にしてあると思います。
そのような場合は、本記事を参考にしていただけると非常に嬉しいです!
(間違っているところがありましたら、コメントしていただけると助かります。。。)
ではでは!