Why I stopped writing enums in TypeScript

Enums were the first thing I reached for when I started with TypeScript. Then I read what they compile to. A const object does the same job with less magic, and TypeScript 4.9 just added the piece that was missing.

TypeScript 4.9 came out two weeks ago and most of the chatter was about satisfies. It's a small keyword, and it closed the last gap in a pattern I've been using instead of enum for a while now.

Enums were the first TypeScript feature I used, back when I was porting my PHP habits over. They look like the thing you know from every other language. They are not quite that.

What an enum compiles to

ts
// src/direction.ts
enum Direction {
  Up,
  Down,
}
js
// dist/direction.js
var Direction;
(function (Direction) {
  Direction[Direction["Up"] = 0] = "Up";
  Direction[Direction["Down"] = 1] = "Down";
})(Direction || (Direction = {}));

A function that runs at load time and builds an object with keys in both directions: Direction.Up is 0 and Direction[0] is 'Up'. That reverse mapping is why Object.keys(Direction) gives you four entries for two members, and why the first time you log one you stare at a 0 wondering which one it was.

String enums drop the reverse mapping, which is better, and bring their own surprise:

ts
enum Tone {
  Neutral = 'neutral',
  Danger = 'danger',
}

const tone: Tone = 'danger';
// Type '"danger"' is not assignable to type 'Tone'.

The value is the string 'danger' and the type refuses the string 'danger'. String enums are nominal: only Tone.Danger is a Tone. Everything else in TypeScript is structural, so this one corner behaves differently from the rest of the language, and the plain string that comes from the API has to be cast before it fits.

Numeric enums go the other way. const d: Direction = 42 compiles today without a word, any number is a Direction.

And const enum, the one that inlines the values and leaves nothing at runtime? It needs the compiler to see every file at once. Vite, esbuild and Babel transpile one file at a time, so they either turn it into a regular enum or give up, and isolatedModules warns you off it for a reason.

What I write instead

For most cases the answer is no runtime value at all:

ts
type Direction = 'up' | 'down';

A string literal union. It is the string, it compares with ===, it's JSON out of the box, and autocomplete still lists the options. If you never iterate over the values and don't need a namespace to hang them on, this is it.

When I do need the values at runtime, a const object:

ts
// src/tone.ts
export const Tone = {
  Neutral: 'neutral',
  Danger: 'danger',
} as const;

export type Tone = (typeof Tone)[keyof typeof Tone];
// 'neutral' | 'danger'

Two things to notice. as const is what makes the values literal types instead of string, without it Tone would be string and the whole exercise is pointless. And the value and the type share the name Tone, which TypeScript allows because values and types live in separate namespaces. Callers write Tone.Danger or 'danger', both type check, and Object.values(Tone) gives you exactly two strings for a dropdown.

The compiled output is the object. No function, no reverse map.

The missing piece

The const object had one hole. If I wanted to make sure every entry was a string, I'd annotate it:

ts
export const Tone: Record<string, string> = {
  Neutral: 'neutral',
  Danger: 'danger',
} as const;

And the annotation wins. Tone is now Record<string, string>, the literal types are gone and keyof typeof Tone is string. Checking the shape cost me the inference.

TypeScript 4.9 adds satisfies, which checks without changing:

ts
export const Tone = {
  Neutral: 'neutral',
  Danger: 'danger',
} as const satisfies Record<string, string>;

The object is checked against the record, a number in there is an error, and the inferred type is still the two literals. as const satisfies reads a bit odd and I expect to type it a thousand more times.

When an enum is still fine

If a library expects one, use theirs. If your team has a hundred string enums and they all work, nothing is on fire. And if you're writing a .d.ts for something that already exists, you take what's there. I'm not deleting enums from old code, I just stopped adding new ones.

The handbook has a section called "Objects vs Enums" that says much the same, more politely.

Read more here: