TypeScript enums: use cases and alternatives

[2025-01-19] dev, typescript
(Ad, please don’t block)

In this blog post, we take a closer look at TypeScript enums:

  • How do they work?
  • What are their use cases?
  • What are the alternatives if we don’t want to use them?

The blog post concludes with recommendations for what to use when.

Notation  

For showing inferred types in the source code, I use the npm package ts-expect – e.g.:

// Types of values
expectType<string>('abc');
expectType<number>(123);

// Equality of types
type Pair<T> = [T, T];
expectType<TypeEqual<Pair<string>, [string,string]>>(true);

The basics of TypeScript enums  

There are many different kinds of enums in various programming languages. In TypeScript, an enum defines two things:

  • an object that maps member keys to member values
  • a type that only contains the member values

Note that we are ignoring const enums in this blog post.

Next, we’ll look at various aspects of enums in more detail.

An enum defines an object  

On one hand, an enum is an object that maps member keys to member values. In that way, it works much like an object literal:

enum Color {
  Red = 0,
  Green = 'GREEN',
}
assert.equal(Color.Red, 0);
assert.equal(Color.Green, 'GREEN');
assert.equal(Color['Green'], 'GREEN');

One limitation is that only numbers and strings are allowed as member values.

An enum defines a type  

On the other hand, an enum is also a type that only contains the member values:

let color: Color;
color = Color.Red;
color = Color.Green;
// @ts-expect-error: Type 'true' is not assignable to type 'Color'.
color = true;

There is one important difference between string members and number members: We cannot assign plain strings to color:

// @ts-expect-error: Type '"GREEN"' is not assignable to
// type 'Color'.
color = 'GREEN';

But we can assign a plain number to color – if it is the value of a member:

color = 0;
// @ts-expect-error: Type '123' is not assignable to type 'Color'.
color = 123;

We can check exhaustiveness for enums  

Consider the following enum:

enum Color {
  Red = 0,
  Green = 'GREEN',
}

If we handle the values that a variable of type Color may have, then TypeScript can warn us if we forget one of them. In other words: If we didn’t handle all cases “exhaustively”. That is called an exhaustiveness check. To see how that works, let’s start with the following code:

// @ts-expect-error: Not all code paths return a value.
function colorToString(color: Color) {
  expectType<Color>(color); // (A)
  if (color === Color.Red) {
    return 'red';
  }
  expectType<Color.Green>(color); // (B)
  if (color === Color.Green) {
    return 'green';
  }
  expectType<never>(color); // (C)
}

In line A, color can still have any value. In line B, we have crossed off Color.Red and color can only have the value Color.Green. In line C, color can’t have any value – which explains its type never.

If color is not never in line C then we have forgotten a member. We can let TypeScript report an error at compile time like this:

function colorToString(color: Color) {
  if (color === Color.Red) {
    return 'red';
  }
  if (color === Color.Green) {
    return 'green';
  }
  throw new UnsupportedValueError(color);
}

How does that work? The value we pass to UnsupportedValueError must have the type never:

class UnsupportedValueError extends Error {
  constructor(value: never, message = `Unsupported value: ${value}`) {
    super(message)
  }
}

This is what happens if we forget the second case:

function colorToString(color: Color) {
  if (color === Color.Red) {
    return 'red';
  }
  // @ts-expect-error: Argument of type 'Color.Green'
  // is not assignable to parameter of type 'never'.
  throw new UnsupportedValueError(color);
}

Exhaustiveness checking works just as well with case statements:

function colorToString(color: Color) {
  switch (color) {
    case Color.Red:
      return 'red';
    case Color.Green:
      return 'green';
    default:
      throw new UnsupportedValueError(color);
  }
}

Another way to check exhaustiveness is by specifying a return type for the function:

// @ts-expect-error: Function lacks ending return statement and
// return type does not include 'undefined'.
function colorToString(color: Color): string {
  switch (color) {
    case Color.Red:
      return 'red';
  }
}

In my code, I usually do that but additionally throw an UnsupportedValueError because I like having a check that also works at runtime.

Enumerating members  

One operation that is occasionally useful is enumerating the members of an enum. Can we do that with TypeScript enums? Let’s use our previous enum:

enum Color {
  Red = 0,
  Green = 'GREEN',
}

Compiled to JavaScript, Color looks like this:

var Color;
(function (Color) {
    Color[Color["Red"] = 0] = "Red"; // (A)
    Color["Green"] = "GREEN"; // (B)
})(Color || (Color = {}));

This is a function that is immediately invoked and adds properties to an object Color.

The code for the string member Green in line B is straightforward: It maps from key to value.

The code for the number member Red in line A adds two properties for Red instead of one – a mapping from key to value and a mapping from value to key:

Color["Red"] = 0;
Color[0] = "Red";

Note that the zero in the second line is coerced to a string (a property key can only be a string or a symbol). Thus, we can’t even really look up a number this way. We have to convert it to a string first.

Therefore, the number member prevents us from enumerating the keys or values of this enum:

assert.deepEqual(
  Object.keys(Color), ['0', 'Red', 'Green']
);
assert.deepEqual(
  Object.values(Color), ['Red', 0, 'GREEN']
);

If we switch to only string members then enumeration works:

enum Color {
  Red = 'RED',
  Green = 'GREEN',
}

assert.deepEqual(
  Object.keys(Color), ['Red', 'Green']
);
assert.deepEqual(
  Object.values(Color), ['RED', 'GREEN']
);

Enums without explicitly specified values  

We can also create enums without explicitly specifying member values. Then TypeScript specifies them for us and uses numbers:

enum Color {
  Red, // implicitly = 0
  Green, // implicitly = 1
}
assert.equal(Color.Red, 0);
assert.equal(Color.Green, 1);

Use cases for enums  

Making sense of enums and enum-related patterns can quickly get confusing because what an enum is varies widely between programming languages – e.g.:

  • Java’s enums are classes with a fixed set of instances.
  • Rust’s enums are more like algebraic datatypes in functional programming languages. They are loosely related to discriminated unions in TypeScript.

Therefore, we benefit from a narrow definition of the term enum:

  • A fixed set of values.
  • That can be accessed via the keys of an object.

In the following sections, we’ll go through the following use cases for this kind of enum:

  1. Namespace for constants with primitive values
  2. Custom type with unique values
  3. Namespace for constants with object values

We’ll consider how well TypeScript enums work for these use cases. (Spoiler: they work reasonably well for #1 and #2 but can’t be used for #3.) And we’ll look at enum-like patterns that we can use instead.

For each option, we’ll also examine:

  • Can exhaustiveness checks be performed?
  • Can members be enumerated?

Use case: namespace for constants with primitive values  

One way in which enums (or enum-like objects) are sometimes used is simply as a namespace for constants – e.g., the Node.js function fs.access() has a parameter mode whose values are provided via an object that is similar to the following enum:

enum constants {
  F_OK = 0,
  R_OK = 4,
  W_OK = 2,
  X_OK = 1,
  // ...
}

Except for the first value, these are bits that are combined via bitwise Or:

const readOrWrite = constants.R_OK | constants.W_OK;

Enum as namespace for constants with primitive values  

Which enum features are relevant for this use case?

  • One major limitation of enums is that values can only be number or strings.
  • The enum as a type doesn’t matter: The type of parameter mode is number, not constants or something similar. That’s because the values of constants are not an exhaustive list of all possible values of mode.
  • For the same reason, exhaustiveness checks are not relevant in this case.
  • Enumerating members isn’t desirable either. And it’s not something that we can do with number-valued enums anyway.

Alternative to enum: object literal  

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

const constants = {
  __proto__: null,
  F_OK: 0,
  R_OK: 4,
  W_OK: 2,
  X_OK: 1,
};

We 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:

  • The main benefit is that we can use the in operator to check if constants has a given key without worrying about properties inherited from Object.prototype such as .toString.
  • However, Object.keys() and Object.values() ignore inherited properties anyway, so we don’t gain anything there.

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”:

Use case: custom type with unique values  

Sometimes we may want to define our own custom type that has a fixed set of values. For example, booleans don’t always express intentions well. Then an enum can do a better job:

enum Activation {
  Active = 'Active',
  Inactive = 'Inactive',
}

It’s a good practice to explicitly specify string values via =:

  • We get more type safety and can’t accidentally provide numbers where an Activation is expected.
  • We can enumerate the keys and values of Activation.

Enum as custom type with unique values  

Which enum features are relevant for this use case?

  • We will use the type defined by Activation.
  • Exhaustiveness checks are possible and useful.
  • We also may want to enumerate keys or values.

Alternative to enum: object literal  

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

const Activation = {
  __proto__: null,
  Active: 'Active',
  Inactive: 'Inactive',
} as const; // (A)

// Without `as const`, this type would be `string`:
expectType<'Active'>(Activation.Active);

type ActivationType = PropertyValues<typeof Activation>;
expectType<
  TypeEqual<ActivationType, 'Active' | 'Inactive'>
>(true);

The as const in line A enables us to derive ActivationType from Activation via the helper type PropertyValues (which is defined below).

Why is this type called ActivationType and not Activation? 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: It got confused because importing Activation imported both value and type. That’s why I’m using different names – for now.

The helper type PropertyValues looks like this:

type PropertyValues<Obj> = Obj[Exclude<keyof Obj, '__proto__'>];
  • The type Obj[K] contains the values of all properties whose keys are in K.
  • We exclude the key '__proto__' from keyof Obj because TypeScript treats that key as a normal property and that’s not what we want (related GitHub issue).

Let’s explore what the derived type looks like if we don’t use as const:

const Activation = {
  __proto__: null,
  Active: 'Active',
  Inactive: 'Inactive',
};

expectType<string>(Activation.Active);
expectType<string>(Activation.Inactive);

type ActivationType = PropertyValues<typeof Activation>;
expectType<
  TypeEqual<ActivationType, string>
>(true);

Exhaustiveness checks  

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

function activationToString(activation: ActivationType): string {
  switch (activation) {
    case Activation.Active:
      return 'ACTIVE';
    case Activation.Inactive:
      return 'INACTIVE';
    default:
      throw new UnsupportedValueError(activation);
  }
}

Enumerating members  

We can use Object.keys() and Object.values() to enumerate the members of the object Activation:

for (const value of Object.values(Activation)) {
  console.log(value);
}

Output:

Active
Inactive

Using symbols as property values  

One downside of using strings as property values is that ActivationType does not exclude arbitrary strings from being used. We can get more type safety if we use symbols:

const Active = Symbol('Active');
const Inactive = Symbol('Inactive');

const Activation = {
  __proto__: null,
  Active,
  Inactive,
} as const;

expectType<typeof Active>(Activation.Active);

type ActivationType = PropertyValues<typeof Activation>;
expectType<
  TypeEqual<
    ActivationType, typeof Active | typeof Inactive
  >
>(true);

This seems overly complicated: Why the intermediate step of first declaring variables for the symbols before we use them? Why not create the symbols inside the object literal? Alas, that’s a current limitation of as const for symbols: They are not recognized as unique (related GitHub issue):

const Activation = {
  __proto__: null,
  Active: Symbol('Active'),
  Inactive: Symbol('Inactive'),
} as const;

// Alas, the type of Activation.Active is not `typeof Active`
expectType<symbol>(Activation.Active);

type ActivationType = PropertyValues<typeof Activation>;
expectType<
  TypeEqual<ActivationType, symbol>
>(true);

Alternative to enum: union of string literal types  

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';

How does such a type compare to an enum?

Pros:

  • It’s a quick and simple solution.
  • It supports exhaustiveness checks.
  • Renaming members works reasonably well in Visual Studio Code.

Cons:

  • The type members are not unique. We could change that by using symbols but then we’d lose some of the convenience of string literal union types – e.g., we’d have to import the values.
  • We can’t enumerate the members. The next section explains how to change that.

Reifying string literal unions via Sets  

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 can use a Set to reify a string literal union type:

const activation = new Set([
  'Active',
  'Inactive',
] as const);
expectType<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>;
expectType<
  TypeEqual<Activation, 'Active' | 'Inactive'>
>(true);

type SetElementType<S extends Set<any>> =
  S extends Set<infer Elem> ? Elem : never;

Use case: namespace for constants with object values  

Sometimes, it’s useful to have an enum-like construct for looking up richer data – stored in objects. We can’t use objects as enum values, so we’ll have to use other solutions.

Object literal whose property values are objects  

This is an example of using an object literal as an enum for objects:

// This type is optional: It constrains the property values
// of `TextStyle` but has no other use.
type TTextStyle = {
  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, TTextStyle>;

type TextStyleType = PropertyValues<typeof TextStyle>;
type PropertyValues<Obj> = Obj[Exclude<keyof Obj, '__proto__'>];

Exhaustiveness check  

Why do the property values of TextStyle have the property .key? That property lets us do exhaustiveness checks because the property values form a discriminated union.

function f(textStyle: TextStyleType): string {
  switch (textStyle.key) {
    case TextStyle.Bold.key:
      return 'BOLD';
    case TextStyle.Italics.key:
      return 'ITALICS';
    default:
      throw new UnsupportedValueError(textStyle); // No `.key`!
  }
}

For comparison, this is what f() would look like if TextStyle were an enum:

enum TextStyle2 { Bold, Italics }
function f2(textStyle: TextStyle2): string {
  switch (textStyle) {
    case TextStyle2.Bold:
      return 'BOLD';
    case TextStyle2.Italics:
      return 'ITALICS';
    default:
      throw new UnsupportedValueError(textStyle);
  }
}

Enum class  

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

class TextStyle {
  static Bold = new TextStyle(/*...*/);
  static Italics = new TextStyle(/*...*/);
}
type TextStyleKeys = EnumKeys<typeof TextStyle>;
expectType<
  TypeEqual<TextStyleKeys, 'Bold' | 'Italics'>
>(true);

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

One pro of this pattern is that we can use methods to add behavior to the enum values. A con is that there is no simple way to get an exhaustiveness check.

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']
);

Mapping to and from an enum  

Sometimes we want to translate enum values to other values or vice versa – e.g. when serializing them to JSON or deserializing them from JSON. If we do so via a Map, we can use TypeScript to get a warning if we forget an enum value.

To explore how that works, we’ll use the following enum pattern type:

const Pending = Symbol('Pending');
const Ongoing = Symbol('Ongoing');
const Finished = Symbol('Finished');
const TaskStatus = {
  __proto__: null,
  Pending,
  Ongoing,
  Finished,
} as const;
type TaskStatusType = PropertyValues<typeof TaskStatus>;
type PropertyValues<Obj> = Obj[Exclude<keyof Obj, '__proto__'>];

This is the Map:

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

type Key = (typeof taskPairs)[number][0];
const taskMap = new Map<Key, string>(taskPairs);

If you are wondering why we didn’t directly use the value of taskPairs as the argument of new Map() and omit the type parameters: TypeScript doesn’t seem to be 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 taskPairs = [
  ['Pending', 'not yet'],
  ['Ongoing', 'working on it'],
  ['Finished', 'finished'],
] as const;
const taskMap = new Map(taskPairs); // no type parameters!

The final step is to check if we forgot only of the values of TaskStatus:

expectType<
  TypeEqual<MapKey<typeof taskMap>, TaskStatusType>
>(true);
type MapKey<M extends Map<any, any>> =
  M extends Map<infer K, any> ? K : never;

Recommendations  

When should we use an enum and when an alternative pattern?

TypeScript enums are not JavaScript: Enums are one of the few TypeScript language constructs (vs. type constructs) that have no corresponding JavaScript features. That can matter in two ways:

  • The transpiled code looks a bit strange – especially if some enum members are numbers.
  • If a tool doesn’t transpile TypeScript but only strips types then it won’t support enums. That’s not (yet?) that common but one prominent example is Node’s current built-in support for TypeScript.

Performance of strings: One thing to keep in mind is that comparing strings is usually slower than comparing numbers or symbols. Therefore, enums or enum patterns where values are strings, are slower. Note that applies to string literal unions too. But such a performance cost only matters if we do many comparisons.

What is the use case? Looking at the use cases can help us make a decision:

  1. Namespace for constants with primitive values:
    • If the primitive values are numbers or strings, we can use a TypeScript enum.
      • Alas, number values aren’t great because each member produces two properties: a mapping from key to value and a reverse mapping.
    • Otherwise (or if we don’t want to use an enum) we can use an object literal.
  2. Custom type with unique values:
    • If we use an enum, it should have string values because that gives us more type safety and lets us iterate over keys and values.
    • A union of string literal types is a lightweight, quick solution. Its downsides are: less type safety and no namespace object for easy lookup.
      • If we want to access the string literal values at runtime, we can use a Set to reify them.
    • If we want a solid, slightly verbose solution, we can use an object literal with symbol property values.
  3. Namespace for constants with object values:
    • We can’t use enums for this use case.
    • We can use an object literal whose property values are objects. The upside of this solution is that we can check exhaustiveness.
    • If we want enum values to have methods, we can use an enum class. However that means that we can’t check exhaustiveness.