Refactor a String Enum to a Literal Union Type
Replace a runtime enum with a union of string literals, narrow at the boundaries, and verify exhaustiveness with a never check so missing cases fail to compile.
Task
Convert a string enum to a union of literal types so the values are erased at runtime and missing cases are caught at compile time.
When to use this
A status enum is used in a switch that silently falls through when a new status is added. The team wants the compiler to flag every missing case, and they want the enum gone from the runtime bundle.
Prerequisites
- TypeScript 5 in strict mode
- A codebase where the enum is used in switch statements
- A test that covers every enum value
Steps
1. Define the union of string literals that replaces the enum.
type Status = "pending" | "active" | "archived";
Expected output: A union type that compiles to nothing at runtime, unlike an enum that emits a reverse mapping object.
2. Narrow the union at every switch and add a never default that fails to compile if a case is missing.
function label(s: Status) {
switch (s) {
case "pending": return "Pending";
case "active": return "Active";
case "archived": return "Archived";
default: const _: never = s; return _;
}
}
Expected output: Adding a new value to Status makes the never assignment fail to compile, pointing at every switch that needs a case.
3. Replace every enum reference with the union and delete the enum declaration.
rg -l "Status\." --type ts | xargs sed -i "s/Status\.pending/"pending"/g"Expected output: No code references the enum, and the bundle no longer contains the reverse mapping object.
Troubleshooting
Problem
A value comes from the network and is not narrowed
Solution
Parse it through a schema that asserts the union, so an unknown value is rejected at the boundary instead of flowing into a switch.
Problem
The never check is too clever for the team
Solution
Extract it into an assertUnreachable helper so every switch reads the same way; the pattern is learned once.
Limitations
- A union type has no runtime presence, so code that relied on Object.values(Enum) needs a separate const array.
- The exhaustiveness check only fires in TypeScript; a JavaScript consumer still needs a default case.
Erase the enum, keep the types
A string enum emits a reverse mapping object at runtime. A union of string literals compiles to nothing. Replace the enum with the union and the bundle shrinks.
type Status = 'pending' | 'active' | 'archived';
Make the compiler check exhaustiveness
Add a never default to every switch. When a new value is added to the union, every switch that misses the case fails to compile, pointing exactly where to fix.
function label(s: Status) {
switch (s) {
case 'pending': return 'Pending';
case 'active': return 'Active';
case 'archived': return 'Archived';
default: const _: never = s; return _;
}
}
Expected outcome
The enum is gone from the runtime bundle, every switch is exhaustive, and adding a new status fails to compile until every case is handled. Values from the network are parsed through a schema that asserts the union, so an unknown value is rejected at the boundary.