Alternatives to enums in TypeScript

[2020-02-22] dev, javascript, typescript
(Ad, please don’t block)

A recent blog post explored how TypeScript enums work. In this blog post, we take a look at alternatives to enums.

Unions of singleton values  

An alternative to creating an enum that maps keys to values, is to create a union of singleton types (one per value). Read on to see how that works.

Unions of string literal types  

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

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

An alternative is to use a type union:

type NoYesStrings = 'No' | 'Yes';

Quick recap:

  • We can consider types to be sets of values.
  • A singleton type is a type with one element.
  • The union type operator | is related to the set-theoretic union operator .

In this case, the operands of the type union are string literal types:

  • 'No' is the type whose only element is the string 'No'
  • 'Yes' is the type whose only element is the string 'Yes'

These types are specified with the same syntax as string literals, but they exist at a different level:

  • String literal type – type level: represents a set with a single string in it
  • String literal – value level: represents a string

Let’s use NoYesStrings in an example:

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

Unions of string literal types are checked for exhaustiveness  

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

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

We forgot the case 'No' and TypeScript warns us that the function is not guaranteed to only return strings, anymore.

Downside: unions of string literals are less type safe  

One downside of string literal unions is that non-member values can mistakenly be considered to be 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 real problem is that there is no way to give them different identities.

Unions of symbol singleton types  

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');
type LogLevel =
  | typeof off
  | typeof info
  | typeof warn
  | typeof error
;

The following function translates members of LogLevel to strings:

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

Let’s compare this approach to unions of string literal types:

  • Exhaustiveness checks work for both approaches.
  • Using symbols is more verbose
  • In contrast to string literal types, symbols are type-safe.

The last point is demonstrated in the following example:

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-ignore: Type 'unique symbol' is not assignable to type 'English'. (2322)
const englishWord: English = spanishNo;

Type unions vs. enums  

Type unions and enums have some things in common:

  • You can auto-complete member values. However, you do it differently:
    • With enums, you get auto-completion after the enum name and a dot.
    • With type unions, you get auto-completion in place or – if it’s a union of string literal types – inside string literal quotes.
  • Exhaustiveness checks work for both.

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

  • They are slightly verbose.
  • There is no namespace for their members.
  • It’s slightly harder to migrate from them to different constructs (should it be necessary): It’s easier to find where enum member values are mentioned.

Upsides of unions of symbol singleton types are:

  • They are not a custom TypeScript language construct and therefore closer to plain JavaScript.
  • String enums are only type-safe at compile time. Unions of symbol singleton types are additionally type-safe at runtime.
    • This matters especially if our compiled TypeScript code interacts with plain JavaScript code.

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:

  • A number
  • The addition of two syntax trees

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

Step 1: the syntax tree as a class hierarchy  

This is a typical OOP 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:

  • Declaring the instance property .numberValue
  • Initializing this property via the parameter numberValue

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.

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

If we define the syntax tree via a type union (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), // trailing comma
  ), // trailing comma
);

Step 3: the syntax tree as a discriminated union  

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

interface NumberValue3 {
  type: 'number-value';
  numberValue: number;
}
interface Addition3 {
  type: '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. Compare:

  • The class of an instance is usually determined by its prototype chain.
  • The type of a member of a discriminated union is determined by its discriminant.

This is a member of SyntaxTree3:

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

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

TypeScript tracks the value of the discriminant and updates the type of the member of the union accordingly:

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

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

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

In line A, we haven’t checked the discriminant .type, yet. Therefore, the current type of tree is still SyntaxTree3 and we can’t access property .numberValue in line B.

In line C, TypeScript knows that .type 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.

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:

  • With classes, it is common to use a polymorphic method where each class has a different implementation.
  • With discriminated unions, it is common to use a single function that handles all possibles cases and decides what to do by examining the discriminant of its parameter.

The following example demonstrates the latter 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.type) { // (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.

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

Note that with the OOP approach, we had to modify the classes in order to add functionality. In contrast, with the functional approach, external parties can add functionality.

Discriminated type unions vs. normal type unions  

Discriminated unions and normal type unions have two things in common:

  • There is no namespace for member values.
  • TypeScript performs exhaustiveness checking.

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

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.

Benefit: 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;

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
type TColor = (typeof Color)[keyof typeof Color]; // (A)

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 should be `never`):
      // %inferred-type: symbol
      color;

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

Alas, in line A, TypeScript infers the type symbol. Accordingly, 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!

We can try and define TColor differently, but that doesn’t change anything:

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

In contrast, if we use constants instead of properties, we get a union between three different values:

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

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

Object literals with string-valued properties  

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

// %inferred-type: "red" | "green" | "blue"
type TColor = (typeof Color)[keyof typeof Color];

We need as const in line A so that TColor isn’t string. Alas, it doesn’t change anything if the property values are symbols.

Using string-valued properties is:

  • Better at development time because we get exhaustiveness checks and a static type for the enum values.
  • Worse at runtime because strings can be mistaken for enum values.

Upsides and downsides of using object literals as enums  

Upsides:

  • We have a namespace for the values.
  • We don’t use a custom construct and are closer to plain JavaScript.
  • Exhaustiveness checks are performed (if we use string-valued properties).
  • There is a narrow type for enum values (if we use string-valued properties).

Downsides:

  • No dynamic membership check is possible (without extra work).
  • Non-enum values can be mistaken for enum values statically or at runtime (if we use string-valued properties).

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-ignore: 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.

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:

  • Unique values: No non-enum value can be mistaken for an enum value.
  • Namespace for enum keys
  • Is it possible to iterate over enum values?
  • Membership check for values at compile time: Is there a narrow type for a set of enum values?
  • Membership check for values at runtime.
    • For the enum pattern, the runtime membership test is instanceof.
    • Note that a membership test can be implemented relatively easily if it is possible to iterate over enum values.
  • Exhaustiveness check (statically by TypeScript)

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.

Acknowledgement  

  • Thanks to Kirill Sukhomlin for his suggestion on how to define TColor for an object literal.

Further reading