Enum or Union String Literal in TypeScript?
1. Overview
When you need to represent a fixed set of values like order statuses or payment methods, TypeScript gives you two popular choices: enum and union string literal ('PENDING' | 'PAID'). Both look similar in your editor but differ at runtime, in how the type checker handles them, and in compatibility with modern tooling.
This article analyzes each approach with concrete examples, points out common pitfalls, and introduces the as const pattern that combines the benefits of both. After reading, you’ll have clear criteria to choose for your project.
2. Enum in TypeScript
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));
Running with npx tsx order.ts, you get:
true
[ 'PENDING', 'PAID', 'SHIPPED', 'CANCELLED' ]
The key point: enum is not just a type. The compiler generates an actual object at runtime. That’s why you can call Object.values(OrderStatus). The generated JavaScript looks like this:
var OrderStatus;
(function (OrderStatus) {
OrderStatus["Pending"] = "PENDING";
OrderStatus["Paid"] = "PAID";
OrderStatus["Shipped"] = "SHIPPED";
OrderStatus["Cancelled"] = "CANCELLED";
})(OrderStatus || (OrderStatus = {}));
2.2. Numeric enum and reverse mapping
Without explicit values, enum numbers from 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' ]
Numeric enum has reverse mapping: Priority[2] returns 'High'. As a result, Object.keys returns both numbers and names. This is a classic bug when rendering a dropdown from an enum and seeing the list duplicated.
Note: Numeric enum still accepts any
numbervariable. For example,const p: Priority = someNumber;compiles fine, even ifsomeNumberis99. Since TypeScript 5.0, assigning a literal outside the range likeconst p: Priority = 99;reports an error. But anumbervariable still slips through.
2.3. Enum’s nominal typing
String enum doesn’t accept the corresponding string literal:
canCancel(OrderStatus.Paid); // OK
canCancel('PAID'); // Error: Argument of type '"PAID"' is not assignable to parameter of type 'OrderStatus'.
You could view this as an advantage: it forces every use to go through OrderStatus. But when data comes from an API’s JSON, you must type cast (as OrderStatus) or write a conversion function.
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 };
// Error: Type '"PAYPAL"' is not assignable to type 'PaymentMethod'.
COD
Union type is completely erased at compile time. The resulting JavaScript contains only the payment object with the string 'COD', with no extra code. Your editor still suggests all three values when you type method: '.
The weakness: since it doesn’t exist at runtime, you can’t iterate over the values of PaymentMethod. You also can’t validate input without declaring a separate list.
3.1. Getting the list of values with as const
The solution is to declare an array first, then infer the type from the array:
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
Explanation:
as constpreserves the literal type of each element. The array has typereadonly ['CARD', 'BANK_TRANSFER', 'COD'], notstring[].(typeof PAYMENT_METHODS)[number]extracts the union of all elements, which is'CARD' | 'BANK_TRANSFER' | 'COD'.isPaymentMethodis a type guard. After the check, TypeScript narrowsvaluetoPaymentMethod.- Casting to
readonly string[]is necessary becauseincludeson a literal tuple only accepts those exact literals.
From now on, the list and type stay in sync. Adding an element to the array automatically updates the type.
4. The as const Object Pattern: A Middle Ground
Many teams like enum’s OrderStatus.Paid syntax but want to avoid its downsides. You can use an object 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
Here:
- The same name
OrderStatusis used for both the value (object) and the type. TypeScript allows this because values and types live in separate namespaces. keyof typeof OrderStatusis'Pending' | 'Paid' | .... Indexed access gives the union of values'PENDING' | 'PAID' | ....- You can still write
OrderStatus.Paid, while directly passing'SHIPPED'from JSON without type casting. - The generated code is just a plain object literal—no IIFE, no reverse mapping.
5. Exhaustive Checking with never
Both enum and union support checking “have we handled all cases?” This is a main reason to use a fixed set of values instead of plain string.
import { OrderStatus } from './order-status';
export function statusLabel(status: OrderStatus): string {
switch (status) {
case 'PENDING':
return 'Awaiting payment';
case 'PAID':
return 'Payment received';
case 'SHIPPED':
return 'In transit';
case 'CANCELLED':
return 'Cancelled';
default: {
const unreachable: never = status;
throw new Error(`Invalid status: ${unreachable}`);
}
}
}
If later you add Refunded: 'REFUNDED' to OrderStatus but forget to add a case, the line const unreachable: never = status; will error:
Type 'string' is not assignable to type 'never'.
More precisely, the message will say '"REFUNDED"' is not assignable to type 'never'. The error appears at compile time, before code reaches production. The throw branch is still useful at runtime when unexpected data leaks in from outside.
6. Writing Tests for Key Behavior
Install Vitest:
npm install -D vitest
Test the type guard and statusLabel function:
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('accepts all values in the list', () => {
for (const method of PAYMENT_METHODS) {
expect(isPaymentMethod(method)).toBe(true);
}
});
it('rejects unknown values', () => {
expect(isPaymentMethod('PAYPAL')).toBe(false);
expect(isPaymentMethod('cod')).toBe(false);
});
});
describe('statusLabel', () => {
it('returns label for all statuses', () => {
const labels = Object.values(OrderStatus).map(statusLabel);
expect(labels).toEqual(['Awaiting payment', 'Payment received', 'In transit', 'Cancelled']);
});
it('throws on invalid runtime data', () => {
expect(() => statusLabel('REFUNDED' as OrderStatus)).toThrow('Invalid status: REFUNDED');
});
});
Run npx vitest run:
✓ PaymentMethod > accepts all values in the list
✓ PaymentMethod > rejects unknown values
✓ statusLabel > returns label for all statuses
✓ statusLabel > throws on invalid runtime data
Test Files 1 passed (1)
Tests 4 passed (4)
The second test proves the type guard is case-sensitive. This is a detail easily missed when parsing user input from forms.
7. Modern Tooling and const enum
More and more tools erase types only, rather than do full TypeScript compilation. Examples: Node.js type-stripping mode, or esbuild and SWC when processing files individually. These tools can’t handle syntax that generates runtime code, like enum.
TypeScript 5.8 has the erasableSyntaxOnly flag to forbid such syntax:
{
"compilerOptions": {
"erasableSyntaxOnly": true,
"isolatedModules": true
}
}
With the flag enabled, any enum declaration errors. Union literals and as const objects remain valid because they are plain JavaScript plus type annotations.
const enum was once promoted as a “free” enum—the compiler inlines values instead of generating an object. However, it causes many problems:
- It’s not safe with
isolatedModules, since a single-file compiler doesn’t know enum values from other files. - When publishing a library,
const enumin.d.tsfiles can break consumers’ code if they don’t usetsc.
Note: Avoid
const enumin new code, especially in libraries. If you want no runtime cost, union literals already do that without the risks.
8. When to Use Each Approach
| Criteria | enum |
Union literal | Object as const |
|---|---|---|---|
| Has runtime value | Yes | No (need separate array) | Yes |
| Accepts raw string from JSON | Must cast | Yes | Yes |
| Compatible with type stripping | No | Yes | Yes |
X.Value syntax |
Yes | No | Yes |
| Exhaustive check | Yes | Yes | Yes |
Practical guidance:
- Union literal: the default for most cases, especially for props, DTOs, and function parameters. The lightest, no runtime code.
- Array
as const+ union: when you need to iterate values (dropdowns, input validation) or use with validation libraries. - Object
as const: when your team wants enum-like syntax, or when names in code differ from actual values (Pendingvs'PENDING'). enum: when your codebase already uses it consistently and builds with fulltsc, or when you really want nominal typing—forbidding raw strings.
Common mistakes:
- Using
Object.keyson numeric enum and getting a list duplicated by reverse mapping. - Letting numeric enum accept any
number, then database values drift when someone inserts a member in the middle. Order changes mean values change. - Casting
as OrderStatusimmediately upon receiving API data, instead of validating with a type guard. The type checker trusts you, but bad data continues on. - Forgetting
as const, causing the type to widen tostringand lose all benefit.
9. Conclusion
Enum and union string literal both solve the “fixed set of values” problem. The difference is that enum generates runtime code and has nominal typing. Union literal exists only at the type level, so it’s lighter and friendlier to modern tooling.
For new TypeScript code, start with union literal. When you need a list at runtime, combine it with an array or object as const. Whichever you choose, use exhaustive checking with never and type guards at system boundaries. That’s what truly makes code safe.