Enums in TypeScript can be a bit of a double-edged sword. They look neat, give you nice tooling, and let you group related constants. But they also come with quirks, runtime implications, and compatibility oddities. If you've ever scratched your head wondering whether to use an enum
or just go with string unions, this guide is for you.
Let's break it all down.
The Classic Enum
Here's a classic numeric enum in TypeScript:
enum Priority {
low = 0,
medium = 1,
high = 2,
critical = 3,
}
You can use it like this:
const p = Priority.high;
console.log(Priority[p]); // 'high'
π Reverse Mapping
With numeric enums, TypeScript generates a reverse mapping, so you can go from 2
to 'high'
.
But there's a catch:
const key = Priority[2]; // inferred as `string`, not 'low' | 'medium' | ...
You lose type safety unless you cast it:
const key = Priority[2] as keyof typeof Priority;
Or wrap it in a helper:
function getEnumKeyByValue<E extends Record<string, string | number>>(
enumObj: E,
value: E[keyof E]
): keyof E | undefined {
return (Object.keys(enumObj) as (keyof E)[]).find(
(k) => enumObj[k] === value
);
}
Why Some Devs Avoid Enums
Enums in TypeScript compile to real JavaScript objects. That means:
- They increase bundle size.
- Structurally identical enums are not compatible.
- You have to remember all the quirks (like reverse mapping only working for numeric enums).
They're not bad β just something to use with eyes open.
The String Union Alternative
Instead of an enum, you can write:
type Priority = 'low' | 'medium' | 'high' | 'critical';
No runtime cost. Super simple. But now you donβt have a runtime object to loop over or validate against.
The Best of Both Worlds: Enum-Like Pattern
Hereβs the slick trick: define a const array and infer the type from it.
const priorityValues = ['low', 'medium', 'high', 'critical'] as const;
type Priority = typeof priorityValues[number];
β
Zero runtime bloat
β
Type-safe union
β
You can iterate over priorityValues
β
You can validate at runtime
function isPriority(input: string): input is Priority {
return priorityValues.includes(input as Priority);
}
π What About Object.fromEntries with Strong Typing?
You can build a typed object from a list of keys using Object.fromEntries
. Here's how:
const entries: [Priority, string][] = priorityValues.map(
(key) => [key, key.toUpperCase()] // just an example transformation
);
const priorityLabels = Object.fromEntries(entries) as Record<Priority, string>;
Now priorityLabels.low
is strongly typed, and accessing an invalid key will be a type error.
Or you can make it reusable with a helper:
function fromEntriesTyped<K extends string, V>(
entries: readonly (readonly [K, V])[]
): Record<K, V> {
return Object.fromEntries(entries) as Record<K, V>;
}
const entries = priorityValues.map((key) => [key, key.toUpperCase()] as const);
const priorityLabels = fromEntriesTyped(entries);
A Reusable Enum Helper
Want to standardize this? Here's a tiny utility:
export function createEnum<const T extends readonly string[]>(values: T) {
return {
values,
includes: (val: unknown): val is T[number] => values.includes(val as T[number]),
type: null as unknown as T[number],
};
}
Usage:
const priorityEnum = createEnum(['low', 'medium', 'high', 'critical'] as const);
type Priority = typeof priorityEnum.type;
const PriorityValues = priorityEnum.values;
const isPriority = priorityEnum.includes;
Now youβve got everything: type safety, runtime values, validation, and no surprises.
TL;DR: When to Use What
Situation | Go With |
---|---|
Small list, no runtime use | β String union |
You want runtime access/iteration | β Const array + type |
Need reverse mapping | β Numeric enum |
Need labels or metadata | β Custom object |
Enums are fine β but if you're aiming for minimalism, clarity, and flexibility, that const array + type combo is a real MVP.
Happy typing! π§βπ»
Top comments (0)