8.3 Heterogeneous Enums and Why to Avoid Them
Now we arrive at the dark, cobwebbed corner of the enum world: the heterogeneous enum. TypeScript, in its infinite and occasionally misguided wisdom, allows you to mix string and numeric members within the same enum. It’s a feature that feels like it was added because they could, not because they should. Let me be direct: you should almost never do this. It’s a code smell of the highest order, a confusing pattern that will make the next developer (who might be you in six months) curse your name.
Here’s the horror in its full, unnerving glory:
enum Chaos {
Up = 1,
Right = "RIGHT",
Down = 2,
Left = "LEFT",
}
Look at that. Just look at it. 1 is a number, "RIGHT" is a string, 2 is a number again… it’s like a badly sorted array. It compiles, because TypeScript’s type system says, “Well, technically, the enum values just have to be constants, and I guess these are all constants…” But it violates the entire principle of an enum: to represent a set of related, uniform constants.
How It (Barely) Works
Under the hood, this abomination works exactly as you’d fear—by generating a bizarre two-faced object. Let’s log it and see the Frankenstein’s monster we’ve created:
console.log(Chaos);
// Logs:
{
'1': 'Up',
'2': 'Down',
Up: 1,
Down: 2,
Right: 'RIGHT',
Left: 'LEFT'
}
See the problem? For the numeric members (Up, Down), we get the reverse mapping. The value 1 maps back to the name 'Up'. But for the string members (Right, Left), there is no reverse mapping. Chaos["RIGHT"] is undefined. This inconsistency isn’t a bug; it’s by design. Numeric enums need reverse mappings for things like enum.values(), but string enums don’t because the value is often just the lowercased name. Mixing them creates an object that behaves differently depending on which key you poke.
The Practical Nightmares
This inconsistency leads directly to bugs. Imagine you write a function that tries to get a key from a value, a common operation:
function getDirectionName(value: number | string): string {
// This will work for Chaos.Up -> "Up"
// This will fail spectacularly for Chaos.Right -> undefined
return Chaos[value];
}
console.log(getDirectionName(Chaos.Up)); // "Up" ✅
console.log(getDirectionName(Chaos.Right)); // undefined 💥
Your code now has to perform type checks to handle members of the same enum in different ways. You’ve successfully introduced more complexity and potential failure points than if you’d just used two separate, sane enums.
Why You Might Think You Need One (And Why You’re Wrong)
The only conceivable reason to create a heterogeneous enum is if you’re trying to mirror some bizarre external data structure you can’t control—like a deeply regrettable JSON API response from a legacy system written in the 1990s.
// DON'T DO THIS, even if the API does.
enum LegacyApiResponse {
Success = 0,
Failure = "ERROR",
}
// DO THIS INSTEAD. Use a union type of literal values.
type ProperApiResponse = 0 | "ERROR";
// Or, if you need the named keys, use two separate enums.
enum StatusCode { Success = 0, Failure = 1 }
enum ErrorCode { Generic = "ERROR" }
The union type is almost always the better choice. It’s simpler, more explicit, and doesn’t come with any of the bizarre reverse-mapping baggage. You’re accurately modeling the data without inventing a false hierarchy.
The Verdict: Just Don’t
Heterogeneous enums are a perfect example of a language feature that exists for completeness but has no place in well-architected, modern code. They combine the worst aspects of numeric and string enums—the potential for accidental numbers from numeric enums and the lack of reverse mapping from string enums—into a single, confusing construct.
The best practice is brutally simple: avoid them entirely. If you find yourself starting to type one, take it as a sign that your data model is unclear. Step back and use a union type, or separate your constants into logical, homogeneous groups. Your colleagues, your linter, and your future self will thank you for not introducing this particular brand of chaos into the codebase.