A class-based enum pattern for JavaScript

[2020-01-13] dev, javascript, pattern
(Ad, please don’t block)

In this blog post, we examine a pattern for implementing enums in JavaScript that is based on classes. We’ll also take a look at Enumify, a library that helps with the enum pattern.

Implementing enums: first attempts  

An enum is a type that consists of a set of values. For example, TypeScript has built-in enums and with them, we can define our own boolean type:

enum MyBoolean {
  false,
  true,
}

Or we can define our own type for colors:

enum Color {
  red,
  orange,
  yellow,
  green,
  blue,
  purple,
}

This TypeScript code is compiled to the following JavaScript code (a few details are omitted, to make things easier to understand):

const Color = {
  red: 0,
  orange: 1,
  yellow: 2,
  green: 3,
  blue: 4,
  purple: 5,
};

This implementation has several problems:

  1. Logging: If you log an enum value such as Color.red, you don’t see its name.
  2. Type safety: Enum values are not unique, they can be mixed up with other numbers. For example, the number 1 can be mistaken for Color.green and vice versa.
  3. Membership check: You can’t easily check whether a given value is an element of Color.

Continuing with plain JavaScript, we can fix problem #1 by using strings instead of numbers as enum values:

const Color = {
  red: 'red',
  orange: 'orange',
  yellow: 'yellow',
  green: 'green',
  blue: 'blue',
  purple: 'purple',
}

We additionally get type safety if we use symbols as enum values:

const Color = {
  red: Symbol('red'),
  orange: Symbol('orange'),
  yellow: Symbol('yellow'),
  green: Symbol('green'),
  blue: Symbol('blue'),
  purple: Symbol('purple'),
}
assert.equal(
  String(Color.red), 'Symbol(red)');

One problem with symbols is that we need to convert them to strings explicitly, we can’t coerce them (e.g. via + or inside template literals):

assert.throws(
  () => console.log('Color: '+Color.red),
  /^TypeError: Cannot convert a Symbol value to a string$/
);

And while we can test for membership, it’s not simple:

function isMember(theEnum, value) {
  return Object.values(theEnum).includes(value);
}
assert.equal(isMember(Color, Color.blue), true);
assert.equal(isMember(Color, 'blue'), false);

The enum pattern  

Using a custom class for the enum gives us a membership test and more flexibility with regard to enum values:

class Color {
  static red = new Color('red');
  static orange = new Color('orange');
  static yellow = new Color('yellow');
  static green = new Color('green');
  static blue = new Color('blue');
  static purple = new Color('purple');

  constructor(name) {
    this.name = name;
  }
  toString() {
    return `Color.${this.name}`;
  }
}

I call this way of using classes as enums the enum pattern. It is inspired by how Java implements enums.

Logging:

console.log('Color: '+Color.red);

// Output:
// 'Color: Color.red'

Membership test:

assert.equal(
  Color.green instanceof Color, true);

Enumify: a helper library for the enum pattern  

Enumify is a library that helps with the enum pattern. It is used as follows:

class Color extends Enumify {
  static red = new Color();
  static orange = new Color();
  static yellow = new Color();
  static green = new Color();
  static blue = new Color();
  static purple = new Color();
  static _ = this.closeEnum();
}

Instance properties  

Enumify adds several instance properties to enum values:

assert.equal(
  Color.red.enumKey, 'red');
assert.equal(
  Color.red.enumOrdinal, 0);

Prototype methods  

Enumify implements .toString():

assert.equal(
  'Color: ' + Color.red, // .toString()
  'Color: Color.red');

Static features  

Enumify sets up two static properties – .enumKeys and .enumValues:

assert.deepEqual(
  Color.enumKeys,
  ['red', 'orange', 'yellow', 'green', 'blue', 'purple']);
assert.deepEqual(
  Color.enumValues,
  [ Color.red, Color.orange, Color.yellow,
    Color.green, Color.blue, Color.purple]);

It provides the inheritable static method .enumValueOf():

assert.equal(
  Color.enumValueOf('yellow'),
  Color.yellow);

And it implements inheritable iterability:

for (const c of Color) {
  console.log('Color: ' + c);
}
// Output:
// 'Color: Color.red'
// 'Color: Color.orange'
// 'Color: Color.yellow'
// 'Color: Color.green'
// 'Color: Color.blue'
// 'Color: Color.purple'

Examples of using Enumify  

Enum values with instance properties  

class Weekday extends Enumify {
  static monday = new Weekday(true);
  static tuesday = new Weekday(true);
  static wednesday = new Weekday(true);
  static thursday = new Weekday(true);
  static friday = new Weekday(true);
  static saturday = new Weekday(false);
  static sunday = new Weekday(false);
  static _ = this.closeEnum();
  constructor(isWorkDay) {
    super();
    this.isWorkDay = isWorkDay;
  }
}
assert.equal(Weekday.sunday.isWorkDay, false);
assert.equal(Weekday.wednesday.isWorkDay, true);

Switching over enum values  

Downside of the enum pattern: Generally, we can’t refer to other enum values when creating an enum values (because those other enum values may not exist yet). As a work-around, we can implement helpers externally, via functions:

class Weekday extends Enumify {
  static monday = new Weekday();
  static tuesday = new Weekday();
  static wednesday = new Weekday();
  static thursday = new Weekday();
  static friday = new Weekday();
  static saturday = new Weekday();
  static sunday = new Weekday();
  static _ = this.closeEnum();
}
function nextDay(weekday) {
  switch (weekday) {
    case Weekday.monday:
      return Weekday.tuesday;
    case Weekday.tuesday:
      return Weekday.wednesday;
    case Weekday.wednesday:
      return Weekday.thursday;
    case Weekday.thursday:
      return Weekday.friday;
    case Weekday.friday:
      return Weekday.saturday;
    case Weekday.saturday:
      return Weekday.sunday;
    case Weekday.sunday:
      return Weekday.monday;
    default:
      throw new Error();
  }
}

Enum values with instance getters  

Another work-around for not being able to use other enum values when declaring enum values is to delay accessing sibling values via getters:

class Weekday extends Enumify {
  static monday = new Weekday({
    get nextDay() { return Weekday.tuesday }
  });
  static tuesday = new Weekday({
    get nextDay() { return Weekday.wednesday }
  });
  static wednesday = new Weekday({
    get nextDay() { return Weekday.thursday }
  });
  static thursday = new Weekday({
    get nextDay() { return Weekday.friday }
  });
  static friday = new Weekday({
    get nextDay() { return Weekday.saturday }
  });
  static saturday = new Weekday({
    get nextDay() { return Weekday.sunday }
  });
  static sunday = new Weekday({
    get nextDay() { return Weekday.monday }
  });
  static _ = this.closeEnum();
  constructor(props) {
    super();
    Object.defineProperties(
      this, Object.getOwnPropertyDescriptors(props));
  }
}
assert.equal(
  Weekday.friday.nextDay, Weekday.saturday);
assert.equal(
  Weekday.sunday.nextDay, Weekday.monday);

The getters are passed to the constructor inside objects. The constructor copies them to the current instance via Object.defineProperties() and Object.getOwnPropertyDescriptors(). Alas, we can’t use Object.assign() here because it can’t copy getters and methods.

State machines via instance methods  

In the following example, we implement a state machine. We pass properties (including methods) to the constructor, which copies them into the current instance.

class State extends Enumify {
  static start = new State({
    done: false,
    accept(x) {
      if (x === '1') {
        return State.one;
      } else {
        return State.start;
      }
    },
  });
  static one = new State({
    done: false,
    accept(x) {
      if (x === '1') {
        return State.two;
      } else {
        return State.start;
      }
    },
  });
  static two = new State({
    done: false,
    accept(x) {
      if (x === '1') {
        return State.three;
      } else {
        return State.start;
      }
    },
  });
  static three = new State({
    done: true,
  });
  static _ = this.closeEnum();
  constructor(props) {
    super();
    Object.defineProperties(
      this, Object.getOwnPropertyDescriptors(props));
  }
}
function run(state, inputString) {
  for (const ch of inputString) {
    if (state.done) {
      break;
    }
    state = state.accept(ch);
    console.log(`${ch} --> ${state}`);
  }
}

The state machine detects sequences of three '1' inside strings:

run(State.start, '01011100');

// Output:
// '0 --> State.start'
// '1 --> State.one'
// '0 --> State.start'
// '1 --> State.one'
// '1 --> State.two'
// '1 --> State.three'

Arbitrary enum values  

One occasionally requested feature for enums is that enum values be numbers (e.g. for flags) or strings (e.g. to compare with values in HTTP headers). That can be achieved by making those values properties of enum values. For example:

class Mode extends Enumify {
  static user_r = new Mode(0b100000000);
  static user_w = new Mode(0b010000000);
  static user_x = new Mode(0b001000000);
  static group_r = new Mode(0b000100000);
  static group_w = new Mode(0b000010000);
  static group_x = new Mode(0b000001000);
  static all_r = new Mode(0b000000100);
  static all_w = new Mode(0b000000010);
  static all_x = new Mode(0b000000001);
  static _ = this.closeEnum();
  constructor(n) {
    super();
    this.n = n;
  }
}
assert.equal(
  Mode.user_r.n | Mode.user_w.n | Mode.user_x.n |
  Mode.group_r.n | Mode.group_x.n |
  Mode.all_r.n | Mode.all_x.n,
  0o755);
assert.equal(
  Mode.user_r.n | Mode.user_w.n | Mode.user_x.n |
  Mode.group_r.n,
  0o740);

Support for public static fields  

The enum pattern and Enumify are based on public static fields. Support for them currently looks as follows:

Further reading