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! 🧑‍💻