TypeScriptでEnumかUnion String Literalか?
1. 概要
注文ステータスや支払い方法など、固定された値の集合を表現する必要がある場合、TypeScriptはenumと文字列ユニオン型('PENDING' | 'PAID')という2つの一般的な選択肢を提供します。この2つはエディタでは似たように見えますが、ランタイムでの振る舞い、型チェッカーの処理方法、最新のツーリングとの互換性が異なります。
この記事では、それぞれの方法を具体的な例で分析し、よくある落とし穴を指摘し、as constパターンで両者の利点を結合する方法を紹介します。読み終わったら、プロジェクトに合った選択基準が明確になります。
2. TypeScriptのEnum
2.1. String enum
enum OrderStatus {
Pending = 'PENDING',
Paid = 'PAID',
Shipped = 'SHIPPED',
Cancelled = 'CANCELLED',
}
function canCancel(status: OrderStatus): boolean {
return status === OrderStatus.Pending || status === OrderStatus.Paid;
}
console.log(canCancel(OrderStatus.Paid));
console.log(Object.values(OrderStatus));
npx tsx order.tsで実行すると、次の結果が得られます:
true
[ 'PENDING', 'PAID', 'SHIPPED', 'CANCELLED' ]
重要なポイント: enumは単なる型ではありません。コンパイラはランタイムに実際のオブジェクトを生成します。これがObject.values(OrderStatus)を呼び出せる理由です。生成されるJavaScriptコードは次のようになります:
var OrderStatus;
(function (OrderStatus) {
OrderStatus["Pending"] = "PENDING";
OrderStatus["Paid"] = "PAID";
OrderStatus["Shipped"] = "SHIPPED";
OrderStatus["Cancelled"] = "CANCELLED";
})(OrderStatus || (OrderStatus = {}));
2.2. 数値enumと逆マッピング
値を割り当てない場合、enumは0から番号が付きます:
enum Priority {
Low,
Medium,
High,
}
console.log(Priority.High);
console.log(Priority[2]);
console.log(Object.keys(Priority));
2
High
[ '0', '1', '2', 'Low', 'Medium', 'High' ]
数値enumは逆マッピングを持ちます: Priority[2]は'High'を返します。その結果、Object.keysは数字と名前の両方を返します。これが、enumからドロップダウンをレンダリングするときにリストが重複する古典的な落とし穴です。
注意: 数値enumでも、任意の
number型の変数を受け入れます。例えばconst p: Priority = someNumber;はコンパイルが通ります。someNumberが99でも構いません。TypeScript 5.0から、範囲外のリテラルを直接割り当てる場合(const p: Priority = 99;)はエラーになります。ただしnumber型の変数はまだ通ります。
2.3. Enumの名目型としての性質
String enumは対応する文字列リテラルを受け入れません:
canCancel(OrderStatus.Paid); // OK
canCancel('PAID'); // エラー: Argument of type '"PAID"' is not assignable to parameter of type 'OrderStatus'.
これは利点と見なすことができます: すべての場所でOrderStatusを経由する必要があります。しかし、データがAPIのJSONから来る場合、型キャスト(as OrderStatus)するか、変換関数を書く必要があります。
3. Union string literal
type PaymentMethod = 'CARD' | 'BANK_TRANSFER' | 'COD';
interface Payment {
orderId: string;
method: PaymentMethod;
amount: number;
}
const payment: Payment = { orderId: 'ORD-001', method: 'COD', amount: 250_000 };
console.log(payment.method);
// const wrong: Payment = { orderId: 'ORD-002', method: 'PAYPAL', amount: 1 };
// エラー: Type '"PAYPAL"' is not assignable to type 'PaymentMethod'.
COD
Union型はコンパイル時に完全に削除されます。結果のJavaScriptは'COD'という文字列を含むオブジェクトpaymentだけで、余分なコードはありません。エディタは、method: 'と入力するときでも3つの値すべてを提案します。
弱点: ランタイムに存在しないため、PaymentMethodの値を反復処理できません。追加のリストを宣言しない限り、入力を検証することもできません。
3.1. as constで値のリストを取得
解決策は、最初に配列を宣言してから、その配列から型を推論することです:
export const PAYMENT_METHODS = ['CARD', 'BANK_TRANSFER', 'COD'] as const;
export type PaymentMethod = (typeof PAYMENT_METHODS)[number];
export function isPaymentMethod(value: string): value is PaymentMethod {
return (PAYMENT_METHODS as readonly string[]).includes(value);
}
console.log(PAYMENT_METHODS);
console.log(isPaymentMethod('COD'), isPaymentMethod('PAYPAL'));
[ 'CARD', 'BANK_TRANSFER', 'COD' ]
true false
説明:
as constは各要素のリテラル型を保持します。配列はstring[]ではなく、readonly ['CARD', 'BANK_TRANSFER', 'COD']という型を持ちます。(typeof PAYMENT_METHODS)[number]はすべての要素のユニオンを取得します。つまり'CARD' | 'BANK_TRANSFER' | 'COD'です。isPaymentMethodは型ガードです。チェック後、TypeScriptはvalueをPaymentMethodに絞り込みます。readonly string[]へのキャストが必要なのは、タプルリテラル上のincludesはそのリテラルだけを受け入れるためです。
これからは、リストと型が常に同期します。配列に要素を追加すると、型は自動的に更新されます。
4. パターン as const オブジェクト: 両者の中間
多くのチームはenumのOrderStatus.Paidという構文を好みますが、enumの欠点は避けたいと考えています。オブジェクトにas constを使うことができます:
export const OrderStatus = {
Pending: 'PENDING',
Paid: 'PAID',
Shipped: 'SHIPPED',
Cancelled: 'CANCELLED',
} as const;
export type OrderStatus = (typeof OrderStatus)[keyof typeof OrderStatus];
function canCancel(status: OrderStatus): boolean {
return status === OrderStatus.Pending || status === OrderStatus.Paid;
}
console.log(canCancel(OrderStatus.Paid));
console.log(canCancel('SHIPPED'));
true
false
ここで:
- 同じ名前
OrderStatusが値(オブジェクト)と型の両方に使われています。ValueとTypeは異なるネームスペースにあるため、TypeScriptはこれを許可します。 keyof typeof OrderStatusは'Pending' | 'Paid' | ...です。インデックスアクセスは値のユニオン'PENDING' | 'PAID' | ...を返します。OrderStatus.Paidと書くことができ、同時にJSONから直接'SHIPPED'を渡すことができるため、型キャストは不要です。- 生成されるコードは単なる通常のオブジェクトリテラルで、即座に実行される関数式や逆マッピングはありません。
5. neverによる網羅的チェック
enumもユニオン型も、「すべてのケースを処理したか」をチェックするのをサポートしています。これが、stringの代わりに固定された値の集合を使う主な理由です。
import { OrderStatus } from './order-status';
export function statusLabel(status: OrderStatus): string {
switch (status) {
case 'PENDING':
return '支払い待ち';
case 'PAID':
return '支払い済み';
case 'SHIPPED':
return '配送中';
case 'CANCELLED':
return 'キャンセル済み';
default: {
const unreachable: never = status;
throw new Error(`不正なステータス: ${unreachable}`);
}
}
}
後でRefunded: 'REFUNDED'をOrderStatusに追加して、caseの追加を忘れた場合、const unreachable: never = status;という行がエラーを報告します:
Type 'string' is not assignable to type 'never'.
より正確には、エラーメッセージは'"REFUNDED"' is not assignable to type 'never'と表示されます。エラーはコンパイル時に現れ、本番環境に上がる前です。外部からおかしなデータが入ってきたときのために、throwブランチはまだランタイムで役に立ちます。
6. 主な動作のテスト作成
Viestをインストール:
npm install -D vitest
型ガードとstatusLabel関数のテスト:
import { describe, expect, it } from 'vitest';
import { isPaymentMethod, PAYMENT_METHODS } from './payment-method';
import { statusLabel } from './status-label';
import { OrderStatus } from './order-status';
describe('PaymentMethod', () => {
it('リスト内のすべての値を受け入れる', () => {
for (const method of PAYMENT_METHODS) {
expect(isPaymentMethod(method)).toBe(true);
}
});
it('未知の値を拒否する', () => {
expect(isPaymentMethod('PAYPAL')).toBe(false);
expect(isPaymentMethod('cod')).toBe(false);
});
});
describe('statusLabel', () => {
it('すべてのステータスに対してラベルを返す', () => {
const labels = Object.values(OrderStatus).map(statusLabel);
expect(labels).toEqual(['支払い待ち', '支払い済み', '配送中', 'キャンセル済み']);
});
it('ランタイムデータが不正な場合はエラーをスロー', () => {
expect(() => statusLabel('REFUNDED' as OrderStatus)).toThrow('不正なステータス: REFUNDED');
});
});
npx vitest runを実行:
✓ PaymentMethod > リスト内のすべての値を受け入れる
✓ PaymentMethod > 未知の値を拒否する
✓ statusLabel > すべてのステータスに対してラベルを返す
✓ statusLabel > ランタイムデータが不正な場合はエラーをスロー
Test Files 1 passed (1)
Tests 4 passed (4)
2番目のテストは、型ガードが大文字と小文字を区別することを証明します。これはフォーム入力を解析するときに見落としやすい詳細です。
7. 最新のツーリングとconst enum
ますます多くのツールが型を削除するだけで、完全なTypeScriptコンパイルを行いません。例えば、Node.jsの型ストリップモード、またはファイルごとに処理するesbuildやSWCがあります。これらのツールはenumのような構文でランタイムコードを生成することを処理できません。
TypeScript 5.8には、この種の構文を禁止するerasableSyntaxOnlyフラグがあります:
{
"compilerOptions": {
"erasableSyntaxOnly": true,
"isolatedModules": true
}
}
フラグを有効にすると、すべてのenum宣言がエラーを報告します。ユニオンリテラルとas constオブジェクトは有効なままです。なぜなら、それらはプレーンなJavaScript plus型注釈だからです。
const enumは「ゼロコスト」enumとして宣伝されていました。コンパイラは値をインラインにするため、オブジェクトを生成しません。しかし、多くの問題が発生しています:
isolatedModulesでは安全に使用できません。ファイルごとのコンパイラは、他のファイルのenum値を知ることができないためです。- ライブラリを公開する際、
.d.tsファイルのconst enumは、TypeScript以外を使用するユーザーのコードを破壊する可能性があります。
注意: 新しいコードでは
const enumを避けてください。特にライブラリでは。ランタイムコストを避けたい場合、ユニオンリテラルはリスクなしにそれを実現しています。
8. どの方法をいつ使うか?
| 基準 | enum |
Union literal | Object as const |
|---|---|---|---|
| ランタイムに値がある | あり | なし(別途配列が必要) | あり |
| JSONから生の文字列を受け入れる | 型キャスト必須 | あり | あり |
| 型ストリップとの互換性 | なし | あり | あり |
X.Value構文 |
あり | なし | あり |
| 網羅的チェック | あり | あり | あり |
実用的なアドバイス:
- Union literal: ほとんどの場合のデフォルト。特にプロパティ型、DTO、関数パラメータに最適。最も簡潔で、ランタイムコードがありません。
as const付き配列 + ユニオン: 値を反復処理する必要がある場合(ドロップダウン、入力検証)、または検証ライブラリと一緒に使用する場合。as constオブジェクト: チームがenumに似た構文を望むか、コード内の表示名が実際の値と異なる場合(Pending対'PENDING')。enum: コードベースが一貫してenumを使用していて、tscでフル構築するか、名目型(プレーンな文字列の渡却を禁止すること)が本当に必要な場合。
よくある落とし穴:
- 数値enumで
Object.keysを使い、逆マッピングによるリストの重複を受け取る。 - 数値enumが任意の
numberを受け入れることを見落とし、後でenumに成員を挿入するとDBのデータが変わる。順序の変更は値の変更をもたらします。 - APIからデータを受け取るときに即座に
as OrderStatusで型キャストし、型ガードで検証しない。型チェッカーはあなたを信じ、不正なデータは先に進みます。 as constを忘れて、型がstringに広がり、すべての効果が失われる。
9. まとめ
enumもユニオン文字列リテラルも「固定された値の集合」という問題を解決します。違いは、enumがランタイムコードを生成し、名目型を持つことです。ユニオンリテラルは型層にのみ存在するため、軽量で最新のツーリングに親切です。
新しいTypeScriptコードでは、ユニオンリテラルから始めるべきです。ランタイムでリストが必要なら、配列またはオブジェクトas constを追加します。どの方法を選んでも、neverを使った網羅的チェックと、システム境界での型ガードを使用してください。これこそが、コードを本当に安全にするものです。