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

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'.

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:
- Create a folder, open it in VS Code, and run
npm install -D typescriptin the terminal (Terminal > New Terminal). - Run
npx tsc --initand keep"strict": trueintsconfig.json. - Save the code below as
enum_asconst.ts. - Run
npx tsc, thennode 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

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'.

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?
| Situation | Pick | Why |
|---|---|---|
| Values passed around as plain strings | String literal union | Accepts literals, no extra code |
| You need a runtime list of values | as const array or object | One source for the type and the list |
Named constants like Plan.Pro | as const object or enum | Both read the same at the call site |
| Numeric codes with readable names | Numeric enum | Reverse mapping from number to name |
Node type stripping or erasableSyntaxOnly | Union or as const | Enums aren’t erasable syntax |
| Every case must be handled | Either, with a never check | The 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:
- Iterate over enums in TypeScript
- Create a custom type from enum values
- satisfies vs as in TypeScript
- Check if a value exists in an enum
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.

Bijay Kumar is a 13-time Microsoft MVP with more than 18 years in software development, and the founder of Python Guides and TSinfo Technologies. He started out building .NET and SharePoint solutions at HP, TCS and KPIT before moving into Python, machine learning and AI, and he also builds web apps with TypeScript and React. He writes the tutorials here himself, and every example is run before publishing so you see the real output. More about Bijay · Microsoft MVP profile · LinkedIn