40.5 Enums vs Const Assertions vs Union of Literals: Choosing Wisely
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.