Right, let’s settle this. You’ve got a value that can only be a few specific things. Maybe it’s the status of an API request: 'idle', 'loading', 'success', 'error'. Your first instinct is to reach for a tool to enforce this. In TypeScript, you’ve got three main contenders, and the classic enum is often the wrongest choice. Let’s break down why.

The Classic Enum: Often the Worst Choice

You come from another language, you see enum, you think, “Aha! A known quantity.” TypeScript eagerly awaits to disappoint you. Behold the “ambient authority” of the classic enum:

enum RequestStatus {
  Idle,
  Loading,
  Success,
  Error, // This has a numeric value of 3
}

function handleStatus(status: RequestStatus) {
  if (status === RequestStatus.Success) {
    console.log('We did it!');
  }
}

// This... works? Wait, why?
handleStatus(3); // No error. 3 is the same as RequestStatus.Error.
handleStatus(999); // Also no error. This is a problem.

See that? Because enums are ultimately just numbers at runtime, TypeScript’s type checking has a heart attack. Any number is assignable to a numeric enum type, which is spectacularly useless for type safety. You’ve just recreated a stringly-typed language but with numbers. Fantastic.

You can make a string enum, which is better, but introduces its own brand of absurdity:

enum StringRequestStatus {
  Idle = 'idle',
  Loading = 'loading',
  Success = 'success',
  Error = 'error',
}

// The compiled JavaScript output is this glorious monstrosity:
/*
var StringRequestStatus;
(function (StringRequestStatus) {
    StringRequestStatus["Idle"] = "idle";
    StringRequestStatus["Loading"] = "loading";
    StringRequestStatus["Success"] = "success";
    StringRequestStatus["Error"] = "error";
})(StringRequestStatus || (StringRequestStatus = {}));
*/

You get a runtime object that exists whether you need it or not. It’s fine, I guess, if you really need to map from a value back to its name at runtime. But for most cases, it’s just boilerplate. It’s a thing that exists because the committee designing TypeScript felt they had to appease the C# and Java developers. I get it. But we can do better.

The Union of Literals: Lean and Mean

This is usually what you actually want. It’s a type, and only a type. It doesn’t generate any code. It vanishes after compilation. It’s pure, beautiful, type-level documentation.

type RequestStatus = 'idle' | 'loading' | 'success' | 'error';

function handleStatus(status: RequestStatus) {
  // ... same logic
}

handleStatus('success'); // Perfect.
handleStatus('fulfilled'); // Error: Argument of type '"fulfilled"' is not assignable to type 'RequestStatus'.
handleStatus(3); // Error: Argument of type '3' is not assignable to type 'RequestStatus'.

Perfect type safety. No runtime overhead. The pitfall here is obvious: if you need to reference these values in multiple places, you’re repeating the string 'success' everywhere. That’s a DRY (Don’t Repeat Yourself) violation waiting to happen. You could define constants, but then you’re back to managing both a type and a value. Which brings us to…

The Const Assertion: The Best of Both Worlds

This is the secret weapon. You define a plain JavaScript object, but you use as const to tell TypeScript, “Freeze this. The types of these properties are exactly these literal values, not general strings or numbers.”

const REQUEST_STATUS = {
  IDLE: 'idle',
  LOADING: 'loading',
  SUCCESS: 'success',
  ERROR: 'error',
} as const;

// Derive the type from the object itself. This is the magic.
type RequestStatus = (typeof REQUEST_STATUS)[keyof typeof REQUEST_STATUS];
// This evaluates to: 'idle' | 'loading' | 'success' | 'error'

// Now you get both a value AND a type, with a single source of truth.
function handleStatus(status: RequestStatus) {
  if (status === REQUEST_STATUS.SUCCESS) {
    console.log('We did it!');
  }
}

handleStatus(REQUEST_STATUS.SUCCESS); // Ideal: using the constant value
handleStatus('success'); // Also works, but now you have two ways to do it.

The beauty here is that REQUEST_STATUS is a real object you can use at runtime, so you avoid magic strings. And the RequestStatus type is automatically derived from it. If you add a new status, the type updates instantly. It’s self-synchronizing.

So, Which One Do You Use?

Here’s your cheat sheet:

  • Use a Union of Literals ('a' | 'b' | 'c'): When you’re defining a type for function parameters, return values, or interfaces, and the values are simple, well-known literals that don’t need a named constant. It’s the zero-cost abstraction.

  • Use a Const Assertion (with as const): This is your default for most cases. You need a set of related constants and a type derived from them. You want a single source of truth to avoid duplication and keep things in sync. It’s the pragmatic, robust choice.

  • Use an Enum (maybe a string enum): Almost never. The only semi-valid use case is if you need to do a reverse mapping (get the key name from the value) and you really don’t want to write the helper function yourself. But let’s be honest, you’re smart enough to write a 2-line function to avoid introducing an entire legacy pattern into your codebase. You are. I believe in you.