Now, let’s talk about the elephant in the room: the growing sentiment that you might not need enums at all. I know, I know, I just spent a chapter singing their praises. But hear me out. TypeScript offers a powerfully elegant alternative that often feels more… well, TypeScript-y. It’s the humble union of literal types.

Think of it this way: an enum is a runtime construct that generates a JavaScript object. A union of string or number literals is a purely type-level construct. It vanishes when your code is compiled to JavaScript, leaving behind only the raw, simple primitives you actually use. This isn’t just a philosophical difference; it has real, practical implications.

The Core Idea: Inline Type Annotations

Instead of formally declaring an enum, you define a type alias that is a union of all the possible literal values. It’s shockingly straightforward.

// Instead of this:
enum LogLevel {
  Debug = 'DEBUG',
  Info = 'INFO',
  Error = 'ERROR'
}

// You can do this:
type LogLevel = 'DEBUG' | 'INFO' | 'ERROR';

function logMessage(message: string, level: LogLevel) {
  console.log(`[${level}] ${message}`);
}

// Usage is identical, but you're just passing strings:
logMessage('Everything is on fire', 'ERROR'); // Works perfectly
logMessage('Everything is on fire', 'CRITICAL'); // Error: Type '"CRITICAL"' is not assignable to type 'LogLevel'

The beauty here is its simplicity. You’re not creating a new value, just a new name for a set of existing ones. Your function will only accept those three specific strings, and TypeScript’s type checking is just as robust as it was with the enum.

The Killer Feature: No Runtime Bloat

This is the big one. Let’s look at the compiled JavaScript for both approaches.

Enum Output:

// A whole generated object!
var LogLevel;
(function (LogLevel) {
    LogLevel["Debug"] = "DEBUG";
    LogLevel["Info"] = "INFO";
    LogLevel["Error"] = "ERROR";
})(LogLevel || (LogLevel = {}));

Your LogLevel enum becomes a tangible object that exists in your final bundle. It’s not huge, but it’s something. If you have hundreds of enums across a large codebase, it all adds up.

Union Type Output:

// Absolutely nothing. It's just a type annotation. Poof. Gone.

The union type approach compiles to nothing. The type is erased, and your function call becomes logMessage('Everything is on fire', 'ERROR'). It’s lighter and doesn’t introduce any new runtime artifacts. This is a clear win for bundle size and runtime simplicity.

The Gotcha: No Namespace for Value/Type

The main trade-off with union types is the loss of the namespace. With a classic enum, the enum itself is both a value (the runtime object) and a type. This is incredibly convenient for things like indexing into an object.

// With an Enum, this is clean
enum Status {
  Todo = 'todo',
  InProgress = 'inprogress',
  Done = 'done'
}

const statusText: Record<Status, string> = {
  [Status.Todo]: 'Get to work',
  [Status.InProgress]: 'Busy busy',
  [Status.Done]: 'All done!'
};

With a union type, you lose the value namespace. You can’t write Status.Todo because Status is only a type, not a value. You have to duplicate the literals, which is a maintenance hazard.

// With a Union Type, you have to duplicate the strings. Yuck.
type Status = 'todo' | 'inprogress' | 'done';

const statusText: Record<Status, string> = {
  todo: 'Get to work',        // Duplicated 'todo'
  inprogress: 'Busy busy',    // Duplicated 'inprogress'
  done: 'All done!'           // Duplicated 'done'
};

If you change the literal in the type, you must remember to change it in the object too. This is the single biggest argument against union types for many use cases. It breaks the DRY (Don’t Repeat Yourself) principle.

The Best of Both Worlds? as const Objects

Ah, but we’re clever. We can get most of the benefits of both approaches by using a const object and then deriving a union type from it. This is my personal favorite pattern for many situations.

// 1. Define the object with all values, and freeze it with `as const`
const STATUS_VALUES = {
  Todo: 'todo',
  InProgress: 'inprogress',
  Done: 'done',
} as const; // The magic sauce: makes all properties readonly literals

// 2. Derive the union type from the object's values
type Status = typeof STATUS_VALUES[keyof typeof STATUS_VALUES];
// This evaluates to: type Status = "todo" | "inprogress" | "done"

// 3. Now you have a single source of truth (the object) for both values and the type!
const statusText: Record<Status, string> = {
  [STATUS_VALUES.Todo]: 'Get to work', // Use the value from the object
  [STATUS_VALUES.InProgress]: 'Busy busy',
  [STATUS_VALUES.Done]: 'All done!'
};

function updateStatus(newStatus: Status) { ... } // Type is the clean union

// Usage is clean and refactor-safe:
updateStatus(STATUS_VALUES.Done);

You get a runtime object for value lookup (solving the DRY problem) and a clean, minimal union type for annotations. It’s a few more lines of setup, but it’s often the most robust and maintainable solution. The designers didn’t make this obvious, but once you see it, you can’t unsee it. It’s just too good.