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 number variable. For example, const p: Priority = someNumber; compiles fine, even if someNumber is 99. Since TypeScript 5.0, assigning a literal outside the range like const p: Priority = 99; reports an error. But a number variable 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 const preserves the literal type of each element. The array has type readonly ['CARD', 'BANK_TRANSFER', 'COD'], not string[].
  • (typeof PAYMENT_METHODS)[number] extracts the union of all elements, which is 'CARD' | 'BANK_TRANSFER' | 'COD'.
  • isPaymentMethod is a type guard. After the check, TypeScript narrows value to PaymentMethod.
  • Casting to readonly string[] is necessary because includes on 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 OrderStatus is used for both the value (object) and the type. TypeScript allows this because values and types live in separate namespaces.
  • keyof typeof OrderStatus is '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 enum in .d.ts files can break consumers’ code if they don’t use tsc.

Note: Avoid const enum in 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 (Pending vs 'PENDING').
  • enum: when your codebase already uses it consistently and builds with full tsc, or when you really want nominal typing—forbidding raw strings.

Common mistakes:

  • Using Object.keys on 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 OrderStatus immediately 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 to string and 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.

typescriptenumunion typetype safetybest practices