Enums considered harmful

January 24, 2023typescriptenum

It seems that torwards the end of 2022 the collective hivemind of the TypeScript programming world decided that enums are terrible. While I mostly agree with the premise that TypeScript enums are not good the solutions that are presented often only deal with String enums. For my work on SDL_ts I really need a solution for Numeric enums as SDL is full of them.

Uses for numeric enums really boil down to two use cases: the value must be either exactly one of the specified values or some bitwise combination of them. We'll create the type helpers Enum and Flags to implement the two use cases.

Enum is when the value must be one of the specified values and only one of the specified values and is easy enough to implement:

export type Enum<T> = T[keyof T];

export const MyEnum = {
//           ^? const MyEnum: { readonly One: 1; readonly Two: 2; }
  One: 1,
  Two: 2
} as const;

export type MyEnum = Enum<typeof MyEnum>;
//          ^? type MyEnum = 1 | 2

// This works.
const foo: MyEnum = MyEnum.Two;

// @ts-expect-error This does not.
const bar: MyEnum = 3;

TS Playground Link

The type helper Enum just creates a union of all the values of the object. T could also be more restrictive but it doesn't have to be for our purposes.

To use the helper we create the export const MyEnum first to hold the values and mark it as const. This changes the type of MyEnum from const MyEnum: { One: number; Two: number; } to const MyEnum: { readonly One: 1; readonly Two: 2; }. Then comes the export type MyEnum which passes typeof MyEnum to our Enum helper. The typeof MyEnum tells TypeScript that we're talking about the const that we defined beforehand. The resulting type is a union of 1 | 2.

Flags is a bit more complicated:

declare const _: unique symbol;
export type Flags<T, Name> =
  | {
    [K in keyof T]: { [_]: Name } & T[K];
  }[keyof T]
  | number;

export const MyFlags = {
//           ^? const MyFlags: { readonly One: 1; readonly Two: 2; }
  One: 1,
  Two: 2
} as const;

export type MyFlags = Flags<typeof MyFlags, "MyFlags">;
//          ^? type MyFlags = number | ({ [_]: "MyFlags"; } & 1) | ({ [_]: "MyFlags"; } & 2)

// This works.
const foo: MyFlags = MyFlags.Two;

// This also works.
const bar: MyFlags = MyFlags.One | MyFlags.Two;

// Unfortunately this also works though.
const baz: MyFlags = 4;

TS Playground Link

The Flags type helper looks quite gnarly but let's go through it step by step. First declare const _: unique symbol; just creates a Symbol that we can use at the type level. Then first part of the union ({ [K in keyof T]: { [_]: Name } & T[K]; }[keyof T]) in the type helper can be thought of like calling map on the type and performing what is known as branding or nominal typing on each value of the enum using the string provided in the 2nd parameter of the type helper. The last part of the helper just unions the newly created type together with number.

Using Flags is almost the same as using Enum but we'll have to provide a string for the branding which I just set to be the same as the name of the enum.

Why do the enum values have to be branded? Without the branding TypeScript widens the resulting type to just be number. The examples still work as before but the Intellisense is completely lost. The IDE will just show the type number with no mention of the name of the enum. See this playground link