Emmanuel Akinfulubi

Lesson 14 of 1815 min read

Enums, and What to Use Instead

The one TypeScript feature that emits code you did not write, why that is a problem, and the two-line pattern that replaces it.

By the end: Model a fixed set of values without reaching for enum.

enum is the only feature in this course I will argue against outright.

It solves a real problem — naming a fixed set of values — and it is the one part of TypeScript that adds a runtime construct you did not write. Everything else in the language is erased. enum is not, and that single exception causes a surprising amount of trouble.

There is a two-line pattern that does the same job with none of the drawbacks, and you have already seen it in lesson 07.

What an enum is

A numeric enum, which is the default

enum Direction {
  North,
  South,
  East,
  West,
}

const heading: Direction = Direction.South;

console.log('the member:', Direction.South);
console.log('the variable:', heading);
console.log('all four:', Direction.North, Direction.South, Direction.East, Direction.West);

Output


      

What it compiles to

This is the crux of the argument, so look at it properly.

An enum emits an object you did not write

enum Direction {
  North,
  South,
}

console.log('forward lookup: ', Direction.North, Direction.South);

// And backwards, which is the part nobody asks for.
console.log('reverse lookup: ', Direction[0], Direction[1]);

// So the object has four keys, not two.
console.log('keys:', Object.keys(Direction));
console.log('the whole object:', JSON.stringify(Direction));
What this compiles to
var Direction;
(function (Direction) {
    Direction[Direction["North"] = 0] = "North";
    Direction[Direction["South"] = 1] = "South";
})(Direction || (Direction = {}));
console.log('forward lookup: ', Direction.North, Direction.South);
// And backwards, which is the part nobody asks for.
console.log('reverse lookup: ', Direction[0], Direction[1]);
// So the object has four keys, not two.
console.log('keys:', Object.keys(Direction));
console.log('the whole object:', JSON.stringify(Direction));

Output


      

Iterating an enum is genuinely awkward

enum Direction {
  North,
  South,
  East,
}

// The obvious approach gives you six things.
console.log('naive:', Object.keys(Direction));

// The names only, which requires knowing about the double mapping.
const names = Object.keys(Direction).filter((k) => Number.isNaN(Number(k)));
console.log('names:', names);

// The values only.
const values = Object.values(Direction).filter((v) => typeof v === 'number');
console.log('values:', values);

console.log('');
console.log('This is a lot of ceremony to list three things.');

Output


      

String enums are better, and still not good

A string enum, with no reverse mapping

enum Level {
  Debug = 'debug',
  Info = 'info',
  Error = 'error',
}

const level: Level = Level.Info;
console.log('value:', level);
console.log('keys:', Object.keys(Level));
console.log('values:', Object.values(Level));
What this compiles to
var Level;
(function (Level) {
    Level["Debug"] = "debug";
    Level["Info"] = "info";
    Level["Error"] = "error";
})(Level || (Level = {}));
const level = Level.Info;
console.log('value:', level);
console.log('keys:', Object.keys(Level));
console.log('values:', Object.values(Level));

Output


      

Better: the values are meaningful, and there is no reverse mapping, so iteration behaves. But the emitted object is still there, and now a subtler problem appears.

A string enum will not accept its own string

TS2345
enum Level {
  Debug = 'debug',
  Info = 'info',
}

function log(level: Level, message: string): string {
  return `[${level}] ${message}`;
}

// The value of Level.Info IS the string 'info'. But the type Level is not the
// type string, so this is rejected — even though it would work perfectly.
console.log(log('info', 'started'));

What the compiler says

error TS2345: Argument of type '"info"' is not assignable to parameter of type 'Level'.

The alternative

as const plus a derived union. Two lines, and everything the enum gave you.

The pattern that replaces enum

// One declaration. The array is a real value you can iterate.
const LEVELS = ['debug', 'info', 'error'] as const;

// The type, derived from it. Add to the array and this updates itself.
type Level = (typeof LEVELS)[number];

function log(level: Level, message: string): string {
  return `[${level}] ${message}`;
}

// Plain strings work, because the type IS a union of strings.
console.log(log('info', 'started'));
console.log(log('error', 'stopped'));

// Iteration needs no filtering, because it is just an array.
console.log('all levels:', LEVELS.join(', '));
for (const level of LEVELS) console.log('  -', level);

// And membership checking is a normal array operation.
const candidate = 'info';
console.log('is a level:', LEVELS.includes(candidate as Level));
What this compiles to
// One declaration. The array is a real value you can iterate.
const LEVELS = ['debug', 'info', 'error'];
function log(level, message) {
    return `[${level}] ${message}`;
}
// Plain strings work, because the type IS a union of strings.
console.log(log('info', 'started'));
console.log(log('error', 'stopped'));
// Iteration needs no filtering, because it is just an array.
console.log('all levels:', LEVELS.join(', '));
for (const level of LEVELS)
    console.log('  -', level);
// And membership checking is a normal array operation.
const candidate = 'info';
console.log('is a level:', LEVELS.includes(candidate));

Output


      

Open the compiled output and compare it with the enum panels above. There is one array. No generated object, no double mapping, no import needed at the use site because the type is erased entirely.

And typos are still caught, which was the whole point

TS2345
const LEVELS = ['debug', 'info', 'error'] as const;
type Level = (typeof LEVELS)[number];

function log(level: Level, message: string): string {
  return `[${level}] ${message}`;
}

console.log(log('inof', 'started'));

What the compiler says

error TS2345: Argument of type '"inof"' is not assignable to parameter of type '"debug" | "error" | "info"'.

Side by side

Everything an enum does, and the union equivalent

enum ColourEnum {
  Red = 'red',
  Green = 'green',
}

const COLOURS = ['red', 'green'] as const;
type Colour = (typeof COLOURS)[number];

// 1. Declaring a value.
const a: ColourEnum = ColourEnum.Red;
const b: Colour = 'red';
console.log('declare:', a, b);

// 2. Accepting one as a parameter.
const useEnum = (c: ColourEnum): string => `enum:${c}`;
const useUnion = (c: Colour): string => `union:${c}`;
console.log(useEnum(ColourEnum.Green), useUnion('green'));

// 3. Accepting a plain string from outside.
console.log('union takes a raw string:', useUnion('red'));
console.log('enum needs a cast:', useEnum('red' as ColourEnum));

// 4. Listing the members.
console.log('enum values: ', Object.values(ColourEnum));
console.log('union values:', COLOURS);

// 5. Switching, with exhaustiveness in both cases.
function describe(c: Colour): string {
  switch (c) {
    case 'red': return 'warm';
    case 'green': return 'cool';
  }
}
console.log(describe('red'), describe('green'));

Output


      

If you want the namespace, you can have it

An object plus a derived union, for the best of both

// A frozen object gives you the dotted access an enum has.
const Level = {
  Debug: 'debug',
  Info: 'info',
  Error: 'error',
} as const;

// The union of its values.
type Level = (typeof Level)[keyof typeof Level];

function log(level: Level, message: string): string {
  return `[${level}] ${message}`;
}

// Dotted access, like an enum.
console.log(log(Level.Info, 'started'));

// And plain strings, unlike an enum.
console.log(log('error', 'stopped'));

// Iteration over either side.
console.log('names: ', Object.keys(Level));
console.log('values:', Object.values(Level));

Output


      

The two remaining problems with enum

Node will not run a file containing one

Straight from lesson 01. Because an enum requires code generation rather than deletion, Node's type stripping refuses it outright. If you want your .ts files to run directly with no build step, enum is not available to you.

What happens if you try

// A file containing this, run with `node file.ts`:
//
//   enum Direction { North, South }
//
// gives:
//
//   SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]:
//     TypeScript enum is not supported in strip-only mode
//
// The same is true of constructor parameter properties, as lesson 11 noted.
// TypeScript 5.8 added an `erasableSyntaxOnly` compiler flag that makes the
// compiler reject exactly the constructs Node cannot strip, so your build and
// your runtime agree about what is allowed.
export {};

Numeric enums accept numbers they should not

A hole in numeric enums specifically

enum Status {
  Active = 1,
  Suspended = 2,
}

function describe(status: Status): string {
  switch (status) {
    case Status.Active: return 'active';
    case Status.Suspended: return 'suspended';
    default: return `unrecognised (${status})`;
  }
}

console.log(describe(Status.Active));

// 99 is not a member of Status. Historically numeric enums accepted any
// number at all; that has been tightened, but a cast still gets through and
// the switch has no case for it.
console.log(describe(99 as Status));

Output


      

const enum, briefly

const enum emits nothing and inlines the values at each use site, which sounds like it answers the main objection. It comes with its own problems: it cannot be used across compilation boundaries in some configurations, it is disallowed under isolatedModules (which bundlers require), and it interacts badly with declaration files. The TypeScript team's own guidance treats it as a specialised tool rather than a default, and the union pattern gets you the same output with none of the caveats.

Mentioned so you recognise it, not so you use it

// const enum Direction { North, South }
//
// Emits no object at all, and `Direction.North` becomes the literal 0 at each
// use. Fast, and:
//   - banned under isolatedModules, which most bundlers require
//   - awkward across package boundaries
//   - the inlining is invisible, so a value change needs a full rebuild
//
// Reach for `as const` plus a union instead. Same output, no footnotes.
export {};

When an enum is defensible

Two cases, to be fair to it.

Outside those, the union is the better default, and it is what the rest of this course uses.

One more reason: unions compose, enums do not

const READ = ['get', 'head'] as const;
const WRITE = ['post', 'put', 'delete'] as const;

type ReadMethod = (typeof READ)[number];
type WriteMethod = (typeof WRITE)[number];

// Combining two unions is one line.
type Method = ReadMethod | WriteMethod;

function isSafe(method: Method): boolean {
  return (READ as readonly string[]).includes(method);
}

for (const method of [...READ, ...WRITE]) {
  console.log(method.padEnd(7), isSafe(method) ? 'safe' : 'mutating');
}

// Subsets are just as easy, which is awkward with enums.
type Idempotent = 'get' | 'head' | 'put' | 'delete';
const check: Idempotent = 'put';
console.log('idempotent example:', check);

Output


      

Your turn

  1. Write a numeric enum with three members and log Object.keys of it. Count the entries and explain the number to yourself.
  2. Write a string enum and try to pass a matching plain string to a function expecting it. Read the error, then work out what you would have to do at every API boundary.
  3. Convert that enum to the as const plus (typeof X)[number] pattern. Confirm the plain string now works and that a typo is still caught.
  4. Build the object-plus-union version so you get Level.Info dotted access. Confirm that the const and the type can share a name.
  5. Add a fourth member to your as const array. Confirm you did not have to update the type, and that a switch with no case for it now fails to compile.
  6. Put an enum in a file and run it with node file.ts. Read the error, then remove the enum and confirm the file runs.

Recap

  • enum is the one TypeScript feature that emits runtime code you did not write, which is the root of every objection to it.
  • A numeric enum produces a double mapping — name to number and back — so Object.keys returns twice as many entries as it has members, and its values are opaque integers.
  • A string enum is nominal: the plain string 'info' is not assignable to it, so every boundary with the outside world needs a cast or a converter.
  • const X = [...] as const plus type X = (typeof X)[number] gives you the same checking with no emitted object, plain-string compatibility and trivial iteration.
  • For dotted access, use a frozen object plus (typeof X)[keyof typeof X]. A type and a value may share a name.
  • Node cannot run a file containing an enum, because stripping types cannot generate the object it needs.

Saved in this browser only.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.