HomepageExploring TypeScript (TS 5.8 Edition)
You can support this book: buy it or donate
(Ad, please don’t block.)

26 Enums and enum patterns

TypeScript has built-in support for enums which are basically namespaces for constants. In this chapter we explore how they work, what patterns we can use instead and how to choose between them.

26.1 Making sense of enums

For TypeScript, it has become desirable to only use JavaScript at the non-type level because that makes compilation easier to understand and faster (thanks to a technique called type stripping). That coding style can be enforced via the compiler option erasableSyntaxOnly).

Enums are one of the few non-type features that are not JavaScript. That’s why, after examining how they work, we’ll also look into JavaScript alternatives: patterns that we can use instead of enums. In order to find them, we’ll focus on three use cases.

26.1.1 Use case 1: namespace for constants with primitive values

This use case is about grouping related constants – think lookup table – e.g.:

const Color_Red = '#FF0000';
const Color_Green = '#00FF00';
const Color_Blue = '#0000FF';

We’d like to have a namespace for these constants and access them via Color.Red etc.

26.1.2 Use case 2: custom type with unique values

Sometimes, we want to define a custom type that has a limited set of values – e.g. to express program states:

const Status_Pending = 'Pending';
const Status_Ongoing = 'Ongoing';
const Status_Finished = 'Finished';
type Status =
  | typeof Status_Pending
  | typeof Status_Ongoing
  | typeof Status_Finished
;

For this use case, we want to be able to check exhaustiveness: When we handle cases (e.g. via switch), TypeScript should warn us during type checking if we forget a case:

function describeStatus(status: Status): string {
  switch (status) {
    case Status_Pending:
      return 'Not yet';
    case Status_Ongoing:
      return 'Working on it...';
    case Status_Finished:
      return 'We are done';
    default:
      throw new UnexpectedValueError(status);
  }
}

This is a simple version of UnexpectedValueError:

class UnexpectedValueError extends Error {
  constructor(value: never) {
    // Only solution that can stringify undefined, null, symbols, and
    // objects without prototypes
    super('Unexpected value: ' + {}.toString.call(value));
  }
}

How does it work? TypeScript infers the type never if there are no more values a variable can have. So that’s the type of status after switch checked all values it can have. The constructor UnexpectedValueError produces a type error if its parameter does not have the type never. For more information and a more sophisticated version of UnexpectedValueError, see “Exhaustiveness checks” (§26.4.3.3).

26.1.3 Use case 3: namespace for constants with object values

This is an example of such constants:

const TextStyle_Bold = {
  key: 'Bold',
  html: 'b',
  latex: 'textbf',
};
const TextStyle_Italics = {
  key: 'Italics',
  html: 'i',
  latex: 'textit',
};

Interestingly, this use case expands both use case 1 and use case 2:

TypeScript enums do not support this use case. But there are enum patterns that do.

26.2 TypeScript enums

26.2.1 Number enums

This is a numeric enum:

enum NoYes {
  No = 0,
  Yes = 1, // trailing comma
}

assert.equal(NoYes.No, 0);
assert.equal(NoYes.Yes, 1);

This enum looks and works similarly to an object literal:

This is how NoYes is used:

function toGerman(value: NoYes) {
  switch (value) {
    case NoYes.No:
      return 'Nein';
    case NoYes.Yes:
      return 'Ja';
  }
}
assert.equal(toGerman(NoYes.No), 'Nein');
assert.equal(toGerman(NoYes.Yes), 'Ja');

26.2.2 String enums

Instead of numbers, we can also use strings as enum member values:

enum NoYes {
  No = 'No',
  Yes = 'Yes',
}

assert.equal(NoYes.No, 'No');
assert.equal(NoYes.Yes, 'Yes');

26.2.3 Heterogeneous enums

The last kind of enum is called heterogeneous. The member values of a heterogeneous enum are a mix of numbers and strings:

enum Enum {
  One = 'One',
  Two = 'Two',
  Three = 3,
  Four = 4,
}
assert.deepEqual(
  [Enum.One, Enum.Two, Enum.Three, Enum.Four],
  ['One', 'Two', 3, 4]
);

Heterogeneous enums are not used often because they have few applications.

Alas, TypeScript only supports numbers and strings as enum member values. Other values, such as symbols, are not allowed.

26.2.4 Omitting initializers

We can also completely omit initializers – in which case TypeScript automatically assigns numbers:

enum NoYes {
  No,
  Yes,
}
assert.equal(NoYes.No, 0);
assert.equal(NoYes.Yes, 1);

It’s also possible to only omit some of the initializers. However, my recommendation is to avoid that and either omit none or all. We won’t go into the details of how exactly partial omissions work, but this is a quick example:

enum Omissions {
  A,
  B,
  C = 'C',
  D = 'D',
  E = 8,
  // We can only omit the initializer
  // at the beginning or after a number
  F,
}
assert.equal(Omissions.A, 0);
assert.equal(Omissions.B, 1);
assert.equal(Omissions.C, 'C');
assert.equal(Omissions.D, 'D');
assert.equal(Omissions.E, 8);
assert.equal(Omissions.F, 9);

26.2.5 Quoting enum member names

Similar to JavaScript objects, we can quote the names of enum members:

enum HttpRequestField {
  'Accept',
  'Accept-Charset',
  'Accept-Datetime',
  'Accept-Encoding',
  'Accept-Language',
}
assert.equal(HttpRequestField['Accept-Charset'], 1);

There is no way to compute the names of enum members. Object literals support computed property keys via square brackets.

26.2.6 Casing of enum member names

There are several precedents for naming constants (in enums or elsewhere):

26.2.7 An enum defines a value and a type

Consider the following enum:

enum ShapeKind { Circle, Rectangle }
26.2.7.1 The enum ShapeKind as a value

On one hand, ShapeKind is a value:

const value = ShapeKind.Circle;
assert.equal(value, 0);
26.2.7.2 The enum ShapeKind as a type

On the other hand, ShapeKind is also a type – whose structure is quite different from its value:

type _ = [
  // Type `ShapeKind` is assignable to and from
  // a union of number literal types
  Assert<Assignable<
    0 | 1, ShapeKind
  >>,
  Assert<Assignable<
    ShapeKind, 0 | 1
  >>,
  // Accordingly, the keys of type `ShapeKind` are
  // equal to the keys of type `number`
  Assert<Equal<
    keyof ShapeKind,
    keyof number
  >>,
];

In other words: Even though the value of an enum is similar to an object, its type is quite different from an object type (whose keys are the types of the property keys, etc.).

26.2.7.3 ShapeKind as a namespace for enum member types

Even though ShapeKind is basically a union type, it is additionally a namespace for the types of its members – e.g., the type ShapeKind.Circle is assignable to and from the literal number type 0:

type _ = [
  Assert<Assignable<
    0, ShapeKind.Circle
  >>,
  Assert<Assignable<
    ShapeKind.Circle, 0
  >>,
];

That means we can use enum member types at the type level – e.g. for parameters:

function describeCircle(circle: ShapeKind.Circle): string {
  // ···
}

Or for discriminants in discriminated unions:

type Shape =
  | {
    kind: ShapeKind.Circle,
    center: Point,
  }
  | {
    kind: ShapeKind.Rectangle,
    corner1: Point,
    corner2: Point,
  }
;
type Point = {
  x: number,
  y: number,
}

26.2.8 Values accepted by enum types

26.2.8.1 Values accepted by number enum types

If a parameter has a number enum type, it accepts both enum member values and numbers:

enum Fruit { Apple, Strawberry }
function check(_animal: Fruit) {}

check(Fruit.Apple);
check(0);

However, we can only use numbers that are values of enum members:

// @ts-expect-error: Argument of type '2' is not assignable to parameter of type 'Fruit'.
check(2);
26.2.8.2 Values accepted by string enum types

If a parameter has a string enum type, member values are considered to be unique – it does not accept strings:

enum Fruit { Apple='Apple', Strawberry='Strawberry' }
function check(_animal: Fruit) {}

check(Fruit.Apple);
// @ts-expect-error: Argument of type '"Apple"' is not assignable to parameter of type 'Fruit'.
check('Apple');

26.2.9 Enums at runtime

TypeScript compiles enums to JavaScript objects.

26.2.9.1 Number enums at runtime

As an example, take the following enum:

enum Animal { Dog, Cat }

TypeScript compiles this enum to:

var Animal;
(function (Animal) {
    Animal[Animal["Dog"] = 0] = "Dog";
    Animal[Animal["Cat"] = 1] = "Cat";
})(Animal || (Animal = {}));

In this code, the following assignments are made:

Animal["Dog"] = 0;
Animal["Cat"] = 1;

Animal[0] = "Dog";
Animal[1] = "Cat";

There are two groups of assignments:

TypeScript lets us use the reverse mappings to look up an enum key, given an enum value:

enum Animal { Dog, Cat }
assert.equal(
  Animal[0], 'Dog'
);
26.2.9.2 String enums at runtime

String-based enums have a simpler representation at runtime.

Consider the following enum.

enum Animal {
  Dog = 'DOG!',
  Cat = 'CAT!',
}

It is compiled to this JavaScript code:

var Animal;
(function (Animal) {
    Animal["Dog"] = "DOG!";
    Animal["Cat"] = "CAT!";
})(Animal || (Animal = {}));

TypeScript does not support reverse mappings for string-based enums.

26.2.10 Handling the enum use cases with enums

Let’s get back to the use cases mentioned at the beginning of this chapter.

26.2.10.1 Use case: namespace for constants with primitive values

We can use an enum as a namespace for constants with primitive values:

enum Color {
  Red = '#FF0000',
  Green = '#00FF00',
  Blue = '#0000FF',
}

Enums work well for this use case – but their values can only be numbers or strings.

26.2.10.2 Use case: custom type with unique values

The following enum defines a custom type with unique values:

enum Status {
  Pending = 'Pending',
  Ongoing = 'Ongoing',
  Finished = 'Finished',
}

Benefit of explicitly specify string values via =: We get more type safety and can’t accidentally use string equal to enum values where a Status is expected.

Note that we don’t need to explicitly define a type – Status is already a type.

Exhaustiveness checks for enums are supported:

function describeStatus(status: Status): string {
  switch (status) {
    case Status.Pending:
      return 'Not yet';
    case Status.Ongoing:
      return 'Working on it...';
    case Status.Finished:
      return 'We are done';
    default:
      throw new UnexpectedValueError(status);
  }
}
assert.equal(
  describeStatus(Status.Pending),
  'Not yet'
);

26.3 TypeScript const enums

If an enum is prefixed with the keyword const, it doesn’t have a representation at runtime. Instead, the values of its member are used directly.

26.3.1 Const enums at runtime

Consider the following const enum:

const enum Vegetable {
  Carrot = 'Carrot',
  Onion = 'Onion',
}

function toGerman(vegetable: Vegetable) {
  switch (vegetable) {
    case Vegetable.Carrot:
      return 'Karotte';
    case Vegetable.Onion:
      return 'Zwiebel';
  }
}

If we compile it, the const enum is not represented at runtime. Only the values of its members remain:

function toGerman(vegetable) {
  switch (vegetable) {
    case "Carrot" /* Vegetable.Carrot */:
      return 'Karotte';
    case "Onion" /* Vegetable.Onion */:
      return 'Zwiebel';
  }
}

Compare that to the compilation output of a normal enum Vegetable:

var Vegetable;
(function (Vegetable) {
  Vegetable["Carrot"] = "Carrot";
  Vegetable["Onion"] = "Onion";
})(Vegetable || (Vegetable = {}));
function toGerman(vegetable) {
  switch (vegetable) {
    case Vegetable.Carrot:
      return 'Karotte';
    case Vegetable.Onion:
      return 'Zwiebel';
  }
}

26.3.2 Downsides of const enums

For most projects, it is better to avoid const enums:

The TypeScript handbook describes these and other pitfalls in more detail.

26.4 Enum pattern: enum objects

It’s time to look at plain JavaScript patterns that we can use instead of enums. One common pattern is to define “enum objects” via object literals:

const Tree = {
  Maple: 'MAPLE',
  Oak: 'OAK',
};

26.4.1 Ways of improving object literals

We have just seen the basic form of enum objects. There are several ways in which we can improve that pattern:

  1. Const object via {} as const
  2. Frozen object via Object.freeze({})
  3. null prototype via {__proto__: null}

#1 is required if we want to derive a type from an enum object. The other two improvements are optional. They produce better results but also add visual clutter.

26.4.1.1 as const object literal: deriving a type for enum values and keys

If we apply as const to an object literal, we get more specific property values at the type level:

const Tree = {
  Maple: 'MAPLE',
  Oak: 'OAK',
};
type _1 = Assert<Equal<
  typeof Tree,
  {
    Maple: string,
    Oak: string,
  }
>>;

const TreeAsConst = {
  Maple: 'MAPLE',
  Oak: 'OAK',
} as const;
type _2 = Assert<Equal<
  typeof TreeAsConst,
  {
    readonly Maple: 'MAPLE',
    readonly Oak: 'OAK',
  }
>>;

Deriving a type for the property values. How is that useful? It enables us to create a type for the property values of Tree:

type _ = [
  Assert<Equal<
    ValueOf<typeof Tree>,
    string
  >>,
  Assert<Equal<
    ValueOf<typeof TreeAsConst>,
    'MAPLE' | 'OAK'
  >>,
];

Being able to create a type for enum object values will help us with the use case “custom type with unique values”.

The helper type ValueOf looks like this:

type ValueOf<Obj> = Obj[keyof Obj];

The indexed access type Obj[K] contains the values of all properties whose keys are in K.

Deriving a type for the property keys. We can also derive a type for the keys of Tree. But for that, we don’t need as const:

type _3 = Assert<Equal<
  keyof (typeof Tree),
  'Maple' | 'Oak'
>>;
type _4 = Assert<Equal<
  keyof (typeof TreeAsConst),
  'Maple' | 'Oak'
>>;
26.4.1.2 Frozen object literal: no modifications at runtime

At the JavaScript level, we can freeze objects:

const TreeFrozen = Object.freeze({
  Maple: 'MAPLE',
  Oak: 'OAK',
});

That helps us at runtime because TreeFrozen can’t be changed:

assert.throws(
  () => TreeFrozen.newProp = true,
  /^TypeError: Cannot add property newProp, object is not extensible$/
);
assert.throws(
  () => TreeFrozen.Maple = 'Ash',
  /^TypeError: Cannot assign to read only property 'Maple'/
);

At the type level, Object.freeze() has the same effect as as const:

const frozenObject = Object.freeze({
  prop: 123
});
type _ = Assert<Equal<
  typeof frozenObject,
  {
    readonly prop: 123,
  }
>>;
26.4.1.3 Object literal with null prototype: no inherited properties

We can use the pseudo property key __proto__ to set the prototype of constants to null. That is a good practice because then we don’t have to deal with inherited properties:

Consider the following two versions of Tree:

const Tree = {
  Maple: 'MAPLE',
  Oak: 'OAK',
};
const TreeProtoNull = {
  __proto__: null,
  Maple: 'MAPLE',
  Oak: 'OAK',
};

We can’t use the in operator to check if the enum Tree has a given key because it also considers inherited properties – which are not enum members:

assert.equal(
  'toString' in Tree,
  true
);
assert.equal(
  'toString' in TreeProtoNull,
  false
);

We can also read inherited properties and that looks like as if the enum Tree has more members than it does:

assert.equal(
  typeof Tree.toString,
  'function'
);
assert.equal(
  TreeProtoNull.toString,
  undefined
);

However, Object.keys() and Object.values() are safe to use – they only consider own (non-inherited) properties:

assert.deepEqual(
  Object.keys(Tree),
  ['Maple', 'Oak']
);
assert.deepEqual(
  Object.keys(TreeProtoNull),
  ['Maple', 'Oak']
);

assert.deepEqual(
  Object.values(Tree),
  ['MAPLE', 'OAK']
);
assert.deepEqual(
  Object.values(TreeProtoNull),
  ['MAPLE', 'OAK']
);
26.4.1.4 Isn’t __proto__ deprecated?

Note that __proto__ also exists as a getter and a setter in Object.prototype. This feature is deprecated in favor of Object.getPrototypeOf() and Object.setPrototypeOf(). However, that is different from using this name in an object literal – which is not deprecated.

For more information, check out these sections of “Exploring JavaScript”:

26.4.1.5 Caveat of {__proto__}: additional property at type level

If an object literal uses .__proto__ then TypeScript includes that property at the type level:

const TreeProtoNull = {
  __proto__: null,
  Maple: 'MAPLE',
  Oak: 'OAK',
} as const;
type _ = Assert<Equal<
  typeof TreeProtoNull,
  {
    readonly __proto__: null;
    readonly Maple: 'MAPLE';
    readonly Oak: 'OAK';
  }
>>;

I’d prefer that weren’t the case – given that .__proto__ is not a real property (related GitHub issue).

As a consequence, we have to manually exclude the key '__proto__' when deriving a type from TreeProtoNull via ValueOf (line A):

type TreeProtoNullType = ValueOf<typeof TreeProtoNull>;
// 123 | null

type _Y = [
  Assert<Equal<
    ValueOf<typeof TreeProtoNull>,
    'MAPLE' | 'OAK' | null
  >>,
  Assert<Equal<
    ValueOf<Omit<typeof TreeProtoNull, '__proto__'>>, // (A)
    'MAPLE' | 'OAK'
  >>,
];

Another workaround is to create a helper function for creating enums that sets the prototype to null at runtime but doesn’t use expose .__proto__ at the type level. We’ll do that next.

26.4.2 A helper function for creating enum objects

This is what the enum object pattern looks like if we use all of the improvements mentioned in the previous subsections:

const Tree = Object.freeze({
  __proto__: null,
  Maple: 'MAPLE',
  Oak: 'OAK',
});

Note that we don’t need as const because Object.freeze() has the same effect.

A helper function can make this code slightly less verbose. The result has a type with specific property value types, from which we can derive a type for Tree:

const Tree = createEnum({
  Maple: 'MAPLE',
  Oak: 'OAK',
});
type _ = Assert<Equal<
  typeof Tree,
  {
    readonly Maple: 'MAPLE';
    readonly Oak: 'OAK';
  }
>>;

This is the helper function:

/**
 * Returns an enum object. Adds the following improvements:
 * - Sets the prototype to `null`.
 * - Freezes the object.
 * - The result has the same type as if `as const` had been applied.
 */
function createEnum<
  // The two type variables are necessary so that the result has specific
  // property value types.
  T extends { [idx: string]: V },
  V extends
    | undefined | null | boolean | number | bigint | string | symbol 
    | object
>(enumObj: T): Readonly<T> {
  // Copying `enumObj` is better for performance than Object.setPrototypeOf()
  return Object.freeze({
    __proto__: null,
    ...enumObj,
  });
}

26.4.3 Handling the enum use cases with enum objects

26.4.3.1 Use case: namespace for constants with primitive values

For this use case, an object literal is a very good alternative:

const Color = {
  Red: '#FF0000',
  Green: '#00FF00',
  Blue: '#0000FF',
};

We can use a null prototype and freezing but they are not required in this case.

26.4.3.2 Use case: custom type with unique values

Let’s use an object literal to define the value part of an enum (we’ll get to the type part next):

const Status = {
  Pending: 'Pending',
  Ongoing: 'Ongoing',
  Finished: 'Finished',
} as const; // (A)

Thanks to as const in line A, we can derive a type from Status:

type StatusType = ValueOf<typeof Status>;
type _ = Assert<Equal<
  StatusType, 'Pending' | 'Ongoing' | 'Finished'
>>;

The utility type ValueOf was defined previously.

Why is this type called StatusType and not Status? Since the namespaces of values and types are separate in TypeScript, we could indeed use the same name. However, I’ve had issues when using Visual Studio Code to rename value and type: You can’t do both at the same time and VSC gets confused because importing Status imports both value and type.

A benefit of using the name StatusType and not TStatus is that the former shows up in auto-completions for Status.

26.4.3.3 Exhaustiveness checks

TypeScript supports exhaustiveness checks for unions of literal types. And that’s what StatusType is. Therefore, we can use the same pattern as we did with enums:

function describeStatus(status: StatusType): string { // (A)
  switch (status) {
    case Status.Pending:
      return 'Not yet';
    case Status.Ongoing:
      return 'Working on it...';
    case Status.Finished:
      return 'We are done';
    default:
      throw new UnexpectedValueError(status);
  }
}
assert.equal(
  describeStatus(Status.Pending),
  'Not yet'
);

Note that in line A, the type of status is StatusType, not Status (which is a value).

26.4.4 Using the members of enum objects at the type level

An enum defines a value, a type and types for members. For enum objects, we have to create the latter two manually. We have already derived a type from an enum object. How about the types for members? Consider the following enum object:

const ShapeKind = {
  Circle: 0,
  Rectangle: 1,
} as const;

The members ShapeKind.Circle and ShapeKind.Rectangle only exist as values, not as types:

// @ts-expect-error: Cannot find namespace 'ShapeKind'.
type _ = ShapeKind.Circle;

Therefore, if we want to use those values at the type level, we have to apply typeof to them (line A and line B):

type Shape =
  | {
    key: typeof ShapeKind.Circle, // (A)
    center: Point,
  }
  | {
    key: typeof ShapeKind.Rectangle, // (B)
    corner1: Point,
    corner2: Point,
  }
;
type Point = {
  x: number,
  y: number,
}

26.4.5 Symbols as property values

One downside of using strings as the property values of an enum object is that they are not unique: A derived type accepts both the property values and strings created via string literals (if they are equal to them). We can get more type safety if we use symbols:

const Pending = Symbol('Pending');
const Ongoing = Symbol('Ongoing');
const Finished = Symbol('Finished');

const Status = {
  Pending,
  Ongoing,
  Finished,
} as const;

assertType<typeof Pending>(Status.Pending);

type StatusType = ValueOf<typeof Status>;
type _ = Assert<Equal<
  StatusType,
  typeof Pending | typeof Ongoing | typeof Finished
>>;

The utility type ValueOf was defined previously.

This seems overly complicated: Why the intermediate step of first declaring variables for the symbols before using them? Why not create the symbols inside the object literal? Alas, that’s a current limitation of symbols in as const objects – they don’t produce unique types (related GitHub issue):

const Status = {
  Pending: Symbol('Pending'),
  Ongoing: Symbol('Ongoing'),
  Finished: Symbol('Finished'),
} as const;

// Alas, the type of Status.Pending is not `typeof Pending`
assertType<symbol>(Status.Pending);

// The derived type is `symbol`, not a union type
type StatusType = ValueOf<typeof Status>;
type _ = Assert<Equal<
  StatusType,
  symbol
>>;

26.4.6 Use case: an enum as a namespace for constants with object values

Sometimes, it’s useful to have an enum-like construct for looking up richer data – stored in objects. That’s something enums can’t do, but it is possible via enum objects:

// This type is optional: It constrains the property values
// of `TextStyle` but has no other use.
type TextStyleProp = {
  key: string,
  html: string,
  latex: string,
};
const TextStyle = {
  Bold: {
    key: 'Bold',
    html: 'b',
    latex: 'textbf',
  },
  Italics: {
    key: 'Italics',
    html: 'i',
    latex: 'textit',
  },
} as const satisfies Record<string, TextStyleProp>;

type TextStyleType = ValueOf<typeof TextStyle>;
type ValueOf<Obj> = Obj[keyof Obj];

Because each property value of TextStyle has the property .key with a unique value, TextStyleType is a discriminated union.

26.4.6.1 Exhaustiveness check

Due to TextStyleType being a discriminated union, we can do an exhaustiveness check:

function describeTextStyle(textStyle: TextStyleType): string {
  switch (textStyle.key) {
    case TextStyle.Bold.key:
      return 'Bold text';
    case TextStyle.Italics.key:
      return 'Text in italics';
    default:
      throw new UnexpectedValueError(textStyle); // No `.key`!
  }
}

In the default case, after we have checked all values that textStyle.key can have, textStyle itself has the type never.

26.5 Enum pattern: enum classes

We can also use a class as an enum – a pattern that is borrowed from Java:

class TextStyle {
  static Bold = new TextStyle({
    html: 'b',
    latex: 'textbf',
  });
  static Italics = new TextStyle({
    html: 'i',
    latex: 'textit',
  });
  html: string;
  latex: string;
  constructor(props: TextStyleProps) {
    this.html = props.html;
    this.latex = props.latex;
  }
  wrapHtml(html: string): string {
    return `<${this.html}>${html}</${this.html}>`;
  }
}
type TextStyleProps = {
  html: string,
  latex: string,
};
assert.equal(
  TextStyle.Bold.wrapHtml('Hello!'),
  '<b>Hello!</b>'
);

We can create a type with the static properties of TextStyle:

type TextStyleKeys = EnumKeys<typeof TextStyle>;
type _1 = Assert<Equal<
  TextStyleKeys, 'Bold' | 'Italics'
>>;

type EnumKeys<T> = Exclude<keyof T, 'prototype'>;

// Why exclude 'prototype'?
type _2 = Assert<Equal<
  keyof typeof TextStyle,
  'prototype' | 'Bold' | 'Italics'
>>;

An upside of enum classes is that we can use methods to add behavior to enum values. A downside is that there is no simple way to get an exhaustiveness check: With an as const enum object whose property values are objects, we can create a discriminated union as an associated type – where we can check exhaustiveness. However, each static property of class TextStyle simply has the type TextStyle (the type of the instances of TextStyle) and that prevents us from creating a discriminated union.

Object.keys() and Object.values() ignore non-enumerable properties of TextStyle such as .prototype – which is why we can use them to enumerate keys and values – e.g.:

assert.deepEqual(
  // TextStyle.prototype is non-enumerable
  Object.keys(TextStyle),
  ['Bold', 'Italics']
);

26.6 Enum pattern: string literal unions

A union of string literal types is an interesting alternative to an enum when it comes to defining a type with a fixed set of members:

type Activation = 'Active' | 'Inactive';

What are the pros and cons of this pattern?

Pros:

Cons:

26.6.1 Reifying string literal unions

Reification means creating an entity at the object level (think JavaScript values) for an entity that exists at the meta level (think TypeScript types). We need to do that for string literal unions if, e.g., we want to iterate over their elements (which don’t exist at runtime). Other enum patterns support that out of the box.

26.6.1.1 Deriving a string literal union from a Set

We can use a Set to reify a string literal union type:

const activation = new Set([
  'Active',
  'Inactive',
] as const);
assertType<Set<'Active' | 'Inactive'>>(activation);

// @ts-expect-error: Argument of type '"abc"' is not assignable to
// parameter of type '"Active" | "Inactive"'.
activation.has('abc');
  // Auto-completion works for arguments of .has(), .delete() etc.

// Let’s turn the Set into a string literal union
type Activation = SetElementType<typeof activation>;
type _ = Assert<Equal<
  Activation, 'Active' | 'Inactive'
>>;

type SetElementType<S extends Set<any>> =
  S extends Set<infer Elem> ? Elem : never;
26.6.1.2 Deriving a string literal union from an Array

When it comes to reifying a string literal union, a Set is often the best choice because we can check at runtime if a given string is a member. However, we can also use an Array to do so:

const activation = [
  'Active',
  'Inactive',
] as const;
type Activation = (typeof activation)[number];
type _ = Assert<Equal<
  Activation,
  'Active' | 'Inactive'
>>;

26.7 More things we can do with enums

In this section, we explore more things we can do with enums. We’ll mostly use enum objects but enums and other enum patterns will also be mentioned occasionally.

26.7.1 Enum values as keys in Maps

Sometimes we want use enum values to look up other values. Let’s explore how that works for the following enum object:

const Pending = Symbol('Pending');
const Ongoing = Symbol('Ongoing');
const Finished = Symbol('Finished');
const Status = {
  Pending,
  Ongoing,
  Finished,
} as const;
type StatusType = ValueOf<typeof Status>;

The utility type ValueOf was defined previously.

The following Map uses the values of Status as keys:

const statusPairs = [
  [Status.Pending, 'not yet'],
  [Status.Ongoing, 'working on it'],
  [Status.Finished, 'finished'],
] as const;

type StatusMapKey = (typeof statusPairs)[number][0];
const statusMap = new Map<StatusMapKey, string>(statusPairs);
assertType<
  Map<
    typeof Pending | typeof Ongoing | typeof Finished,
    string
  >
>(statusMap);

If you are wondering why we didn’t directly use the value of statusPairs as the argument of new Map() and omit the type parameters: TypeScript isn’t able to infer the type parameters if the keys are symbols and reports a compile-time error. With strings, the code would be simpler:

const statusMap2 = new Map([ // no type parameters!
  ['Pending', 'not yet'],
  ['Ongoing', 'working on it'],
  ['Finished', 'finished'],
] as const);
assertType<
  Map<
    'Pending' | 'Ongoing' | 'Finished',
    'not yet' | 'working on it' | 'finished'
  >
>(statusMap2);

As a final step, check manually if we used all enum values:

type _ = Assert<Equal<
  MapKey<typeof statusMap>, StatusType // (A)
>>;
type MapKey<M extends Map<any, any>> =
  M extends Map<infer K, any> ? K : never;

In line A, we extract the type of the keys from statusMap and demand that it be equal to StatusType.

26.7.1.1 Exhaustiveness checks via Record

TypeScript can check if a union type is used exhaustively if we use Record:

Record<UnionType, T>

However, such a union type can only have elements that are subtypes of string, number or symbol. That means that Record works well for unions of string literal types:

type Status = 'Pending' | 'Ongoing' | 'Finished';
const statusMap = {
  'Pending': 'not yet',
  'Ongoing': 'working on it',
  // @ts-expect-error: Type '{ Pending: string; Ongoing: string; }' does
  // not satisfy the expected type 'Record<Status, string>'. Property
  // 'Finished' is missing in type '{ Pending: string; Ongoing: string; }'
  // but required in type 'Record<Status, string>'.
} satisfies Record<Status, string>;

26.7.2 Mapping between keys (strings) of enum members and their values

Sometimes it’s useful to map enum keys to values or vice versa. One important use case for that is deserializing enums of symbols or objects from JSON and serializing them to JSON.

const Pending = Symbol('Pending');
const Ongoing = Symbol('Ongoing');
const Finished = Symbol('Finished');
const Status = {
  Pending,
  Ongoing,
  Finished,
} as const;
26.7.2.1 From enum key to enum value

One use case for mapping from the key of an enum member to its value, is parsing JSON data:

function parseEnumKey<
  E extends Record<string, unknown>
>(enumObject: E, enumKey: string): E[keyof E] {
  if (!Object.hasOwn(enumObject, enumKey)) {
    throw new TypeError('Unknown key: ' + {}.toString.call(enumKey));
  }
  return enumObject[enumKey] as any;
}
assert.equal(
  parseEnumKey(Status, 'Ongoing'),
  Status.Ongoing
);

It is possible to make both the type of enumKey and the return type more specific (see next subsection) but then we couldn’t parse values of type string anymore.

26.7.2.2 From enum value to enum key

One use case for mapping from the value of an enum member to its key, is creating JSON data:

function stringifyEnumValue<
  E extends object,
  V extends E[keyof E]
>(enumObject: E, enumValue: V): keyof E {
  for (const [key, value] of Object.entries(enumObject)) {
    if (enumValue === value) {
      return key as any;
    }
  }
  throw new TypeError('Unknown value: ' + {}.toString.call(enumValue));
}
assert.equal(
  stringifyEnumValue(Status, Status.Ongoing),
  'Ongoing'
);

26.7.3 Iterating over enum members

One use case for iterating over enum members is creating a user interface that lists options collected in an enum. Which enum patterns do support iteration?

26.7.4 Specifying bit vectors

One use case for enums is specifying bit vectors (multiple independent bit flags).

26.7.4.1 Specifying bit vectors via bit masks

The traditional way of specifying bit vectors is via bit masks:

const Permission = {
  Read:    1 << 2, // bit 2
  Write:   1 << 1, // bit 1
  Execute: 1 << 0, // bit 0
}
function setPermission(filePath: string, permission: number): void {
  // ···
}
setPermission(
  'read-and-write.txt',
  Permission.Read | Permission.Write
);

For more information on this kind of bit manipulation, see section “Bitwise operators” in “Exploring JavaScript”.

26.7.4.2 Specifying bit vectors via Sets

Another option for specifying bit vectors is via Sets. I often prefer that option.

const Read = Symbol('Read');
const Write = Symbol('Write');
const Execute = Symbol('Execute');
const Permission = {
  Read, Write, Execute
} as const;
type PermissionType = (typeof Permission)[keyof typeof Permission];

function setPermission(filePath: string, permission: Set<PermissionType>): void {
  // ···
}
setPermission(
  'read-and-write.txt',
  new Set([Permission.Read, Permission.Write])
);

26.7.5 Type validation for enums via Zod

Zod is a tool for validating parsed data (often JSON) – checking if the runtime data matches an expected type. That increases type safety when working with untyped data.

Zod also supports enums, but the recommendation is to use string literal unions. They have to be defined via Arrays of strings because Zod needs data that it can use at runtime.

import { z } from 'zod';

const ActivationSchema = z.enum(['Active', 'Inactive']);

// Derive a type from the schema
type Activation = z.infer<typeof ActivationSchema>;
type _ = Assert<Equal<
  Activation,
  'Active' | 'Inactive'
>>;

// Use the schema to “parse” data at runtime
// (check that it has the correct type)
const activation = ActivationSchema.parse('Inactive');
assertType<Activation>(activation);
assert.throws(
  () => ActivationSchema.parse('HELLO')
);

26.8 Recommendations

How should we choose between enums and various enum patterns?

Let’s go through the use cases:

Various considerations: