Tackling TypeScript
Please support this book: buy it or donate
(Ad, please don’t block.)

13 Alternatives to enums in TypeScript



The previous chapter explored how TypeScript enums work. In this chapter, we take a look at alternatives to enums.

13.1 Unions of singleton values

An enum maps member names to member values. If we don’t need or want the indirection, we can use a union of so-called primitive literal types – one per value. Before we can go into details, we need to learn about primitive literal types.

13.1.1 Primitive literal types

Quick recap: We can consider types to be sets of values.

A singleton type is a type with one element. Primitive literal types are singleton types:

type UndefinedLiteralType = undefined;
type NullLiteralType = null;

type BooleanLiteralType = true;
type NumericLiteralType = 123;
type BigIntLiteralType = 123n; // --target must be ES2020+
type StringLiteralType = 'abc';

UndefinedLiteralType is the type with the single element undefined, etc.

It is important to be aware of the two language levels at play here (we have already encountered those levels earlier in this book). Consider the following variable declaration:

const abc: 'abc' = 'abc';

Two use cases for primitive literal types are:

Read on for more information about the second use case.

13.1.2 Unions of string literal types

We’ll start with an enum and convert it to a union of string literal types.

enum NoYesEnum {
  No = 'No',
  Yes = 'Yes',
}
function toGerman1(value: NoYesEnum): string {
  switch (value) {
    case NoYesEnum.No:
      return 'Nein';
    case NoYesEnum.Yes:
      return 'Ja';
  }
}
assert.equal(toGerman1(NoYesEnum.No), 'Nein');
assert.equal(toGerman1(NoYesEnum.Yes), 'Ja');

NoYesStrings is the union type version of NoYesEnum:

type NoYesStrings = 'No' | 'Yes';

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

The type NoYesStrings is the union of the string literal types 'No' and 'Yes'. The union type operator | is related to the set-theoretic union operator .

13.1.2.1 Unions of string literal types can be checked for exhaustiveness

The following code demonstrates that exhaustiveness checks work for unions of string literal types:

// @ts-expect-error: Function lacks ending return statement and
// return type does not include 'undefined'. (2366)
function toGerman3(value: NoYesStrings): string {
  switch (value) {
    case 'Yes':
      return 'Ja';
  }
}

We forgot the case for 'No' and TypeScript warns us that the function may return values that are not strings.

We could have also checked exhaustiveness more explicitly:

class UnsupportedValueError extends Error {
  constructor(value: never) {
    super('Unsupported value: ' + value);
  }
}

function toGerman4(value: NoYesStrings): string {
  switch (value) {
    case 'Yes':
      return 'Ja';
    default:
      // @ts-expect-error: Argument of type '"No"' is not
      // assignable to parameter of type 'never'. (2345)
      throw new UnsupportedValueError(value);
  }
}

Now TypeScript warns us that we reach the default case if value is 'No'.

  More information on exhaustiveness checking

For more information on this topic, see §12.7.2.2 “Protecting against forgetting cases via exhaustiveness checks”.

13.1.2.2 Downside: unions of string literals are less type-safe

One downside of string literal unions is that non-member values can mistaken for members:

type Spanish = 'no' | 'sí';
type English = 'no' | 'yes';

const spanishWord: Spanish = 'no';
const englishWord: English = spanishWord;

This is logical because the Spanish 'no' and the English 'no' are the same value. The actual problem is that there is no way to give them different identities.

13.1.3 Unions of symbol singleton types

13.1.3.1 Example: LogLevel

Instead of unions of string literal types, we can also use unions of symbol singleton types. Let’s start with a different enum this time:

enum LogLevel {
  off = 'off',
  info = 'info',
  warn = 'warn',
  error = 'error',
}

Translated to a union of symbol singleton types, it looks as follows:

const off = Symbol('off');
const info = Symbol('info');
const warn = Symbol('warn');
const error = Symbol('error');

// %inferred-type: unique symbol | unique symbol |
// unique symbol | unique symbol
type LogLevel =
  | typeof off
  | typeof info
  | typeof warn
  | typeof error
;

Why do we need typeof here? off etc. are values and can’t appear in type equations. The type operator typeof fixes this issue by converting values to types.

Let’s consider two variations of the previous example.

13.1.3.2 Variation #1: inlined symbols

Can we inline the symbols (instead of referring to separate const declarations)? Alas, the operand of the type operator typeof must be an identifier or a “path” of identifiers separated by dots. Therefore, this syntax is illegal:

type LogLevel = typeof Symbol('off') | ···
13.1.3.3 Variation #2: let instead of const

Can we use let instead of const to declare the variables? (That’s not necessarily an improvement but still an interesting question.)

We can’t because we need the narrower types that TypeScript infers for const-declared variables:

// %inferred-type: unique symbol
const constSymbol = Symbol('constSymbol');

// %inferred-type: symbol
let letSymbol1 = Symbol('letSymbol1');

With let, LogLevel would simply have been an alias for symbol.

const assertions normally solve this kind of problem. But they don’t work in this case:

// @ts-expect-error: A 'const' assertions can only be applied to references to enum
// members, or string, number, boolean, array, or object literals. (1355)
let letSymbol2 = Symbol('letSymbol2') as const;
13.1.3.4 Using LogLevel in a function

The following function translates members of LogLevel to strings:

function getName(logLevel: LogLevel): string {
  switch (logLevel) {
    case off:
      return 'off';
    case info:
      return 'info';
    case warn:
      return 'warn';
    case error:
      return 'error';
  }
}

assert.equal(
  getName(warn), 'warn');
13.1.3.5 Unions of symbol singleton types vs. unions of string literal types

How do the two approaches compare?

Recall this example where the Spanish 'no' was confused with the English 'no':

type Spanish = 'no' | 'sí';
type English = 'no' | 'yes';

const spanishWord: Spanish = 'no';
const englishWord: English = spanishWord;

If we use symbols, we don’t have this problem:

const spanishNo = Symbol('no');
const spanishSí = Symbol('sí');
type Spanish = typeof spanishNo | typeof spanishSí;

const englishNo = Symbol('no');
const englishYes = Symbol('yes');
type English = typeof englishNo | typeof englishYes;

const spanishWord: Spanish = spanishNo;
// @ts-expect-error: Type 'unique symbol' is not assignable to type 'English'. (2322)
const englishWord: English = spanishNo;

13.1.4 Conclusion of this section: union types vs. enums

Union types and enums have some things in common:

But they also differ. Downsides of unions of symbol singleton types are:

Upsides of unions of symbol singleton types are:

13.2 Discriminated unions

Discriminated unions are related to algebraic data types in functional programming languages.

To understand how they work, consider the data structure syntax tree that represents expressions such as:

1 + 2 + 3

A syntax tree is either:

Next steps:

  1. We’ll start by creating an object-oriented class hierarchy for syntax trees.
  2. Then we’ll transform it into something slightly more functional.
  3. And finally, we’ll end up with a discriminated union.

13.2.1 Step 1: the syntax tree as a class hierarchy

This is a typical object-oriented implementation of a syntax tree:

// Abstract = can’t be instantiated via `new`
abstract class SyntaxTree1 {}
class NumberValue1 extends SyntaxTree1 {
  constructor(public numberValue: number) {
    super();
  }
}
class Addition1 extends SyntaxTree1 {
  constructor(public operand1: SyntaxTree1, public operand2: SyntaxTree1) {
    super();
  }
}

SyntaxTree1 is the superclass of NumberValue1 and Addition1. The keyword public is syntactic sugar for:

This is an example of using SyntaxTree1:

const tree = new Addition1(
  new NumberValue1(1),
  new Addition1(
    new NumberValue1(2),
    new NumberValue1(3), // trailing comma
  ), // trailing comma
);

Note: Trailing commas in argument lists are allowed in JavaScript since ECMAScript 2016.

13.2.2 Step 2: the syntax tree as a union type of classes

If we define the syntax tree via a union type (line A), we don’t need object-oriented inheritance:

class NumberValue2 {
  constructor(public numberValue: number) {}
}
class Addition2 {
  constructor(public operand1: SyntaxTree2, public operand2: SyntaxTree2) {}
}
type SyntaxTree2 = NumberValue2 | Addition2; // (A)

Since NumberValue2 and Addition2 don’t have a superclass, they don’t need to invoke super() in their constructors.

Interestingly, we create trees in the same manner as before:

const tree = new Addition2(
  new NumberValue2(1),
  new Addition2(
    new NumberValue2(2),
    new NumberValue2(3),
  ),
);

13.2.3 Step 3: the syntax tree as a discriminated union

Finally, we get to discriminated unions. These are the type definitions for SyntaxTree3:

interface NumberValue3 {
  kind: 'number-value';
  numberValue: number;
}
interface Addition3 {
  kind: 'addition';
  operand1: SyntaxTree3;
  operand2: SyntaxTree3;
}
type SyntaxTree3 = NumberValue3 | Addition3;

We have switched from classes to interfaces and therefore from instances of classes to plain objects.

The interfaces of a discriminated union must have at least one property in common and that property must have a different value for each one of them. That property is called the discriminant or tag. The discriminant of SyntaxTree3 is .kind. Its types are string literal types.

Compare:

This is an object that matches SyntaxTree3:

const tree: SyntaxTree3 = { // (A)
  kind: 'addition',
  operand1: {
    kind: 'number-value',
    numberValue: 1,
  },
  operand2: {
    kind: 'addition',
    operand1: {
      kind: 'number-value',
      numberValue: 2,
    },
    operand2: {
      kind: 'number-value',
      numberValue: 3,
    },
  }
};

We don’t need the type annotation in line A, but it helps ensure that the data has the correct structure. If we don’t do it here, we’ll find out about problems later.

In the next example, the type of tree is a discriminated union. Every time we check its discriminant (line C), TypeScript updates its static type accordingly:

function getNumberValue(tree: SyntaxTree3) {
  // %inferred-type: SyntaxTree3
  tree; // (A)

  // @ts-expect-error: Property 'numberValue' does not exist on type 'SyntaxTree3'.
  // Property 'numberValue' does not exist on type 'Addition3'.(2339)
  tree.numberValue; // (B)

  if (tree.kind === 'number-value') { // (C)
    // %inferred-type: NumberValue3
    tree; // (D)
    return tree.numberValue; // OK!
  }
  return null;
}

In line A, we haven’t checked the discriminant .kind, yet. Therefore, the current type of tree is still SyntaxTree3 and we can’t access property .numberValue in line B (because only one of the types of the union has this property).

In line D, TypeScript knows that .kind is 'number-value' and can therefore infer the type NumberValue3 for tree. That’s why accessing .numberValue in the next line is OK, this time.

13.2.3.1 Implementing functions for discriminated unions

We conclude this step with an example of how to implement functions for discriminated unions.

If there is an operation that can be applied to members of all subtypes, the approaches for classes and discriminated unions differ:

The following example demonstrates the functional approach. The discriminant is examined in line A and determines which of the two switch cases is executed.

function syntaxTreeToString(tree: SyntaxTree3): string {
  switch (tree.kind) { // (A)
    case 'addition':
      return syntaxTreeToString(tree.operand1)
        + ' + ' + syntaxTreeToString(tree.operand2);
    case 'number-value':
      return String(tree.numberValue);
  }
}

assert.equal(syntaxTreeToString(tree), '1 + 2 + 3');

Note that TypeScript performs exhaustiveness checking for discriminated unions: If we forget a case, TypeScript will warn us.

This is the object-oriented version of the previous code:

abstract class SyntaxTree1 {
  // Abstract = enforce that all subclasses implement this method:
  abstract toString(): string;
}
class NumberValue1 extends SyntaxTree1 {
  constructor(public numberValue: number) {
    super();
  }
  toString(): string {
    return String(this.numberValue);
  }
}
class Addition1 extends SyntaxTree1 {
  constructor(public operand1: SyntaxTree1, public operand2: SyntaxTree1) {
    super();
  }
  toString(): string {
    return this.operand1.toString() + ' + ' + this.operand2.toString();
  }
}

const tree = new Addition1(
  new NumberValue1(1),
  new Addition1(
    new NumberValue1(2),
    new NumberValue1(3),
  ),
);

assert.equal(tree.toString(), '1 + 2 + 3');
13.2.3.2 Extensibility: object-oriented approach vs. functional approach

Each approach does one kind of extensibility well:

13.2.4 Discriminated unions vs. normal union types

Discriminated unions and normal union types have two things in common:

The next two subsections explore two advantages of discriminated unions over normal unions:

13.2.4.1 Benefit: descriptive property names

With discriminated unions, values get descriptive property names. Let’s compare:

Normal union:

type FileGenerator = (webPath: string) => string;
type FileSource1 = string|FileGenerator;

Discriminated union:

interface FileSourceFile {
  type: 'FileSourceFile',
  nativePath: string,
}
interface FileSourceGenerator {
  type: 'FileSourceGenerator',
  fileGenerator: FileGenerator,
}
type FileSource2 = FileSourceFile | FileSourceGenerator;

Now people who read the source code immediately know what the string is: a native pathname.

13.2.4.2 Benefit: We can also use it when the parts are indistinguishable

The following discriminated union cannot be implemented as a normal union because we can’t distinguish the types of the union in TypeScript.

interface TemperatureCelsius {
  type: 'TemperatureCelsius',
  value: number,
}
interface TemperatureFahrenheit {
  type: 'TemperatureFahrenheit',
  value: number,
}
type Temperature = TemperatureCelsius | TemperatureFahrenheit;

13.3 Object literals as enums

The following pattern for implementing enums is common in JavaScript:

const Color = {
  red: Symbol('red'),
  green: Symbol('green'),
  blue: Symbol('blue'),
};

We can attempt to use it in TypeScript as follows:

// %inferred-type: symbol
Color.red; // (A)

// %inferred-type: symbol
type TColor2 = // (B)
  | typeof Color.red
  | typeof Color.green
  | typeof Color.blue
;

function toGerman(color: TColor): string {
  switch (color) {
    case Color.red:
      return 'rot';
    case Color.green:
      return 'grün';
    case Color.blue:
      return 'blau';
    default:
      // No exhaustiveness check (inferred type is not `never`):
      // %inferred-type: symbol
      color;

      // Prevent static error for return type:
      throw new Error();
  }
}

Alas, the type of each property of Color is symbol (line A) and TColor (line B) is an alias for symbol. As a consequence, we can pass any symbol to toGerman() and TypeScript won’t complain at compile time:

assert.equal(
  toGerman(Color.green), 'grün');
assert.throws(
  () => toGerman(Symbol())); // no static error!

A const assertion often helps in this kind of situation but not this time:

const ConstColor = {
  red: Symbol('red'),
  green: Symbol('green'),
  blue: Symbol('blue'),
} as const;

// %inferred-type: symbol
ConstColor.red;

The only way to fix this is via constants:

const red = Symbol('red');
const green = Symbol('green');
const blue = Symbol('blue');

// %inferred-type: unique symbol
red;

// %inferred-type: unique symbol | unique symbol | unique symbol
type TColor2 = typeof red | typeof green | typeof blue;

13.3.1 Object literals with string-valued properties

const Color = {
  red: 'red',
  green: 'green',
  blue: 'blue',
} as const; // (A)

// %inferred-type: "red"
Color.red;

// %inferred-type: "red" | "green" | "blue"
type TColor =
  | typeof Color.red
  | typeof Color.green
  | typeof Color.blue
;

We need as const in line A so that the properties of Color don’t have the more general type string. Then TColor also has a type that is more specific than string.

Compared to using an object with symbol-valued properties as an enum, string-valued properties are:

13.3.2 Upsides and downsides of using object literals as enums

Upsides:

Downsides:

13.4 Enum pattern

The following example demonstrates a Java-inspired enum pattern that works in plain JavaScript and TypeScript:

class Color {
  static red = new Color();
  static green = new Color();
  static blue = new Color();
}

// @ts-expect-error: Function lacks ending return statement and return type
// does not include 'undefined'. (2366)
function toGerman(color: Color): string { // (A)
  switch (color) {
    case Color.red:
      return 'rot';
    case Color.green:
      return 'grün';
    case Color.blue:
      return 'blau';
  }
}

assert.equal(toGerman(Color.blue), 'blau');

Alas, TypeScript doesn’t perform exhaustiveness checks, which is why we get an error in line A.

13.5 Summary of enums and enum alternatives

The following table summarizes the characteristics of enums and their alternatives in TypeScript:

Unique Namesp. Iter. Mem. CT Mem. RT Exhaust.
Number enums - -
String enums -
String unions - - - -
Symbol unions - - -
Discrim. unions - (1) - - - (2)
Symbol properties - - -
String properties - -
Enum pattern -

Titles of table columns:

Footnotes in table cells:

  1. Discriminated unions are not really unique, but mistaking values for union members is relatively unlikely (especially if we use a unique name for the discriminant property).
  2. If the discriminant property has a unique enough name, it can be used to check membership.

13.6 Acknowledgement