TypeScript Enum vs Union Type: Which One Should You Use?

Here’s the short version of TypeScript enum vs union type: an enum creates a real JavaScript object you can loop over, while a string literal union is a type only, erased at compile time, that accepts plain strings.

For most new code I reach for the union, and I keep enums for the cases where I need a runtime object:

enum OrderStatus { Pending = "PENDING", Shipped = "SHIPPED" }  // emits an object
type ShipStatus = "pending" | "shipped";                         // emits nothing

In this tutorial, I’ll compare the two on real code: compiled output, string literals, numeric enums, as const objects, exhaustive switches and erasable syntax. Each example ran under TypeScript 7.0.2 and Node.js v22.22.2 with strict mode on, and the screenshots come straight from VS Code.

What is the difference between an enum and a union type in TypeScript?

An enum is a named set of constants. You write OrderStatus.Shipped, and TypeScript also generates an object that holds those values at runtime. If you’re new to them, my TypeScript enums tutorial covers the basics.

A string literal union is a type made of exact strings joined with |. A value of type ShipStatus can only be one of those strings, and you pass it as a plain string. Here’s the same order status written both ways:

enum OrderStatus {
  Pending = "PENDING",
  Shipped = "SHIPPED",
  Delivered = "DELIVERED",
}

type ShipStatus = "pending" | "shipped" | "delivered";

function enumLabel(s: OrderStatus): string {
  return s === OrderStatus.Shipped ? "on the truck" : s.toLowerCase();
}

function unionLabel(s: ShipStatus): string {
  return s === "shipped" ? "on the truck" : s;
}

const total = 89.5;
console.log(`Order ORD-2071 ($${total.toFixed(2)}):`,
  enumLabel(OrderStatus.Shipped));
console.log("Order ORD-2072:", unionLabel("delivered"));
console.log("Enum value at runtime:", OrderStatus.Pending);

Output:

Order ORD-2071 ($89.50): on the truck
Order ORD-2072: delivered
Enum value at runtime: PENDING

Both functions are fully type-checked. The difference is in what you type at the call site: OrderStatus.Shipped for the enum, and just "delivered" for the union.

What do enums and union types compile to?

This is the biggest practical difference. I compiled one file with a string enum, a union and a const enum, then printed the JavaScript that tsc wrote:

enum OrderStatus {
  Pending = "PENDING",
  Shipped = "SHIPPED",
}

type ShipStatus = "pending" | "shipped";

const enum Carrier {
  UPS = "UPS",
  FedEx = "FEDEX",
}

const a: OrderStatus = OrderStatus.Shipped;
const b: ShipStatus = "shipped";
const c = Carrier.FedEx;
console.log(a, b, c);

Output:

"use strict";
var OrderStatus;
(function (OrderStatus) {
    OrderStatus["Pending"] = "PENDING";
    OrderStatus["Shipped"] = "SHIPPED";
})(OrderStatus || (OrderStatus = {}));
const a = OrderStatus.Shipped;
const b = "shipped";
const c = "FEDEX" /* Carrier.FedEx */;
console.log(a, b, c);
SHIPPED shipped FEDEX
VS Code editor with a TypeScript string enum, a union type and a const enum, terminal showing the compiled JavaScript with an enum IIFE and no code for the union
The enum turns into an object built by an IIFE, the union type disappears, and the const enum value is inlined as a string.

The union left no trace in the output, so it adds zero bytes to your bundle. The regular enum became a small object you can read at runtime. The const enum sits in between, since its uses are replaced with the literal "FEDEX".

That inlining has a catch. Tools that compile one file at a time, like Babel or esbuild with isolatedModules, can’t see a const enum declared in another file, so many teams avoid it.

Why won’t a plain string pass as a string enum?

String enums are nominal. That means TypeScript checks the enum’s name, not just its value, so the string "SHIPPED" isn’t accepted where OrderStatus is expected, even though the values match.

enum OrderStatus {
  Pending = "PENDING",
  Shipped = "SHIPPED",
}
type ShipStatus = "PENDING" | "SHIPPED";

function notifyEnum(s: OrderStatus) { console.log("enum:", s); }
function notifyUnion(s: ShipStatus) { console.log("union:", s); }

notifyUnion("SHIPPED");             // OK
notifyEnum(OrderStatus.Shipped);    // OK
notifyEnum("SHIPPED");              // error

const fromApi = "SHIPPED" as string;
notifyUnion(fromApi);               // error too

Compiler output:

enum_nominal.ts(12,12): error TS2345: Argument of type '"SHIPPED"' is not assignable to parameter of type 'OrderStatus'.
enum_nominal.ts(15,13): error TS2345: Argument of type 'string' is not assignable to parameter of type 'ShipStatus'.
VS Code showing TypeScript error TS2345 when the string SHIPPED is passed to a function that takes a string enum, while the union accepts it
The union takes the string literal, the enum rejects it with TS2345, and a value typed as plain string fails for both.

The last error matters too. A value from an API is just a string, so neither type accepts it without a check. For enums, I show the safe conversions in converting a string to an enum in TypeScript.

How do numeric enums and reverse mapping work?

Numeric enums behave a little differently. Members without a value start at 0, and TypeScript adds a reverse mapping, so you can look up a name from its number.

enum Priority {
  Low,
  Medium,
  High,
}

console.log("Priority.High =", Priority.High);
console.log("Priority[2] =", Priority[2]);
console.log("keys:", Object.keys(Priority).join(", "));

type Tier = 1 | 2 | 3;
const tier: Tier = 2;
console.log("tier:", tier);

Output:

Priority.High = 2
Priority[2] = High
keys: 0, 1, 2, Low, Medium, High
tier: 2

Notice the keys line: the object holds both the numbers and the names. That surprises a lot of people when they loop over a numeric enum. I dig into it in my reverse mapping in TypeScript enums guide.

A union of number literals, like 1 | 2 | 3, gives you the same type safety without the object. You lose the readable names, though, which is a fair reason to keep a numeric enum.

How do you loop over the values of a union type?

Since a union is erased, you can’t loop over it directly. The fix is to start from a readonly array with as const and derive the union from it with (typeof STATUSES)[number].

const STATUSES = ["pending", "shipped", "delivered"] as const;
type ShipStatus = (typeof STATUSES)[number];

enum OrderStatus {
  Pending = "PENDING",
  Shipped = "SHIPPED",
  Delivered = "DELIVERED",
}

function isShipStatus(v: string): v is ShipStatus {
  return (STATUSES as readonly string[]).includes(v);
}

console.log("union values:", STATUSES.join(", "));
console.log("enum values:", Object.values(OrderStatus).join(", "));

for (const raw of ["shipped", "lost"]) {
  console.log(raw, "->", isShipStatus(raw) ? "valid" : "rejected");
}

Output:

union values: pending, shipped, delivered
enum values: PENDING, SHIPPED, DELIVERED
shipped -> valid
lost -> rejected

Now one array drives both the type and the runtime list, and the type guard checks raw input such as a query string. The enum gives you Object.values() for free, as in getting all enum values in TypeScript, but you still write the guard yourself.

If you work with enums a lot, converting an enum to an array in TypeScript shows more ways to list them.

Can an as const object replace an enum?

Yes, and it’s the pattern I use most. A plain object with as const keeps the Plan.Pro style of an enum, and a derived union type accepts plain strings too. It’s what people usually mean by “enum vs object” in TypeScript.

To try it in VS Code yourself:

  1. Create a folder, open it in VS Code, and run npm install -D typescript in the terminal (Terminal > New Terminal).
  2. Run npx tsc --init and keep "strict": true in tsconfig.json.
  3. Save the code below as enum_asconst.ts.
  4. Run npx tsc, then node enum_asconst.js, and check the three lines.
const Plan = {
  Basic: "basic",
  Pro: "pro",
  Team: "team",
} as const;
type Plan = (typeof Plan)[keyof typeof Plan];

const monthly: Record<Plan, number> = {
  basic: 9.99,
  pro: 24.99,
  team: 79.0,
};

function price(p: Plan): string {
  return `$${monthly[p].toFixed(2)}/mo`;
}

console.log("Plan.Pro:", price(Plan.Pro));   // named access
console.log("'team':", price("team"));        // plain literal
console.log("all plans:", Object.values(Plan).join(", "));

Output:

Plan.Pro: $24.99/mo
'team': $79.00/mo
all plans: basic, pro, team
VS Code editor with a TypeScript as const Plan object, a derived union type and USD monthly prices, terminal showing named access and a plain string both working
Plan.Pro and the plain string "team" both pass, and Object.values() lists the three plans.

The type line reads in two steps: keyof typeof Plan gives the keys, and indexing with them gives the values. My TypeScript keyof operator guide explains that part, and Record vs object in TypeScript covers the price table’s Record type.

How do union types give exhaustive switch checks?

A switch over a union can prove it handles every member. In the default branch, assign the value to a never variable. If a case is missing, the leftover member can’t go into never, and the build fails.

I added "returned" to the union but forgot its case:

type ShipStatus = "pending" | "shipped" | "delivered" | "returned";

function eta(s: ShipStatus): string {
  switch (s) {
    case "pending":
      return "ships in 1-2 days";
    case "shipped":
      return "arrives Nov 3, 2026";
    case "delivered":
      return "delivered";
    default: {
      const unhandled: never = s;
      return unhandled;
    }
  }
}
console.log(eta("shipped"));

Compiler output:

enum_exhaustive.ts(12,13): error TS2322: Type '"returned"' is not assignable to type 'never'.
VS Code showing TypeScript error TS2322 in a switch over a string literal union because the returned case is missing from the never check
The missing returned case shows up as an error on the never line, so the bug can’t ship.

This trick works the same with enums. My TypeScript switch case with enums tutorial shows the enum version.

Do enums work with erasableSyntaxOnly and Node type stripping?

No, and this is the newest reason teams pick unions. Node.js can now run .ts files by stripping the types, but that only works for syntax that can be deleted. An enum generates code, so it doesn’t qualify.

TypeScript 5.8 added the --erasableSyntaxOnly flag to catch that early. I compiled an enum with it:

enum OrderStatus {
  Pending = "PENDING",
  Shipped = "SHIPPED",
}
type ShipStatus = "pending" | "shipped";

const s: ShipStatus = "pending";
console.log(s, OrderStatus.Pending);

Compiler output with –erasableSyntaxOnly:

enum_erasable.ts(1,6): error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.

The union line passed, and only the enum was flagged. If your project runs TypeScript directly in Node, a union or an as const object is the safe choice.

When should you use an enum vs a union type?

SituationPickWhy
Values passed around as plain stringsString literal unionAccepts literals, no extra code
You need a runtime list of valuesas const array or objectOne source for the type and the list
Named constants like Plan.Proas const object or enumBoth read the same at the call site
Numeric codes with readable namesNumeric enumReverse mapping from number to name
Node type stripping or erasableSyntaxOnlyUnion or as constEnums aren’t erasable syntax
Every case must be handledEither, with a never checkThe compiler flags missing cases

If you already use enums, you don’t need to rip them out. Comparing them is covered in my TypeScript enum comparison guide, and I’d only switch when one of the rows above bites.

Pick any of these four next if you want more practice with enums and literal types:

Frequently asked questions

Should I use an enum or a union type in TypeScript?

Use a string literal union for most cases, since it adds no code and accepts plain strings. Use an enum when you need a runtime object or numeric reverse mapping.

Is a string enum the same as a string literal union?

No. A string enum is nominal, so the plain string "SHIPPED" fails where the enum is expected. A union accepts the literal directly.

Does a union type exist at runtime?

No. TypeScript erases it. Start from an as const array and derive the union when you need the values at runtime.

What is the difference between an enum and an as const object?

Both give named access like Plan.Pro. The as const object is plain JavaScript and its derived union also accepts string literals.

Why does erasableSyntaxOnly reject enums?

Enums generate JavaScript, so they can’t be removed by just stripping types. The flag reports any syntax that Node’s type stripping can’t handle.

Where are enums documented?

See the Enums page in the TypeScript handbook.