Skip to content

Enum Classes

When to use

Use enum classes when you need a fixed, closed set of named values with identity semantics — states, statuses, categories, or modes. Unlike plain string constants, enum instances support instanceof checks, iteration, and custom methods while preventing new values from being created after initialization.

The pattern

With plain strings, a typo like "runnng" silently passes — nothing catches it. Enum classes give you real objects that can only be one of the declared members, so invalid states fail loudly.

Using an enum

Below is a Timer class that uses TimerState members to guard its transitions — comparing against TimerState.READY instead of a raw string means a typo like "redy" would be a ReferenceError, not a silent bug:

javascript
class Timer {
  #state = TimerState.READY;
  
  start() {
    if (this.#state !== TimerState.READY) {
      throw new Error("Can only start a ready timer");
    }
    this.#state = TimerState.RUNNING;
  }
  
  complete() {
    this.#state = TimerState.COMPLETED;
  }
}

Every Enum subclass is closed to extension — attempting to construct a new member after initialization throws immediately, so no downstream code can introduce a value the enum wasn't designed for:

javascript
new TimerState("PAUSED");
// Error: enum TimerState cannot be instantiated

Defining an enum

The static field initializers execute first, calling new while #sealed is false, so each new member is allowed. Then the static {} block sets #sealed to true and calls Object.seal() on the class. After that, the constructor rejects any further instantiation, and Object.freeze() on each instance prevents mutation. This is the boilerplate you copy for each new enum:

javascript
import { Enum } from "./enum.js";

class TimerState extends Enum {
  static #sealed = false;
  
  static READY = new TimerState("READY");
  static RUNNING = new TimerState("RUNNING");
  static COMPLETED = new TimerState("COMPLETED");
  static CANCELED = new TimerState("CANCELED");
  
  static {
    TimerState.#sealed = true;
    Object.seal(TimerState);
  }
  
  #name;
  
  constructor(name) {
    if (TimerState.#sealed) {
      throw new Error(
        "enum TimerState cannot be instantiated"
      );
    }
    super();
    this.#name = name;
    Object.freeze(this);
  }
  
  get name() {
    return this.#name;
  }
  
  valueOf() {
    return this.#name;
  }
  
  toString() {
    return this.#name;
  }
}

The TimerState class above extends Enum, a small base class that provides the static iteration methods (keys(), values(), entries(), and for...of):

javascript
export class Enum {
  static keys() {
    return Object.keys(this);
  }
  static values() {
    return Object.values(this);
  }
  static entries() {
    return Object.entries(this);
  }
  static [Symbol.iterator]() {
    return this.values().values();
  }
}

Iteration

Enum classes are iterable — TimerState.values() returns all members as an array. This means you can, for example, build UI directly from the enum. Here, .map() turns each member into an <option> element, using .name for the label and String(state) (via valueOf()) for the value attribute. Add a new member to the class and every dropdown, filter, or list that iterates the enum updates automatically — no parallel arrays to keep in sync:

javascript
function renderStatusFilter() {
  return TimerState.values().map(state =>
    `<option value="${state}">${state.name}</option>`
  ).join("\n");
}

This works because Enum.values() calls Object.keys(this) on the subclass, which returns only the static fields — the enum members.

Lookup by value

When data comes from an API or database as a raw string, you need to map it back to an enum member. Below, PromiseStatus is an Enum subclass with a static from() method that searches its members by value. If the string doesn't match any member, from() throws — so bad data fails immediately instead of silently propagating through your system:

javascript
const status = PromiseStatus.from(result.status);

Implementing from()

Each enum subclass adds a from() method that converts a raw string to the matching member, and a matches() method that each member uses to compare against a candidate value. If no member matches, from() throws:

javascript
class PromiseStatus extends Enum {
  // ... #sealed, static members, static {} block ...
  
  static from(value) {
    const member = [...PromiseStatus].find(
      (status) => status.matches(value)
    );
    if (!member) {
      throw new Error(`Unknown PromiseStatus: ${value}`);
    }
    return member;
  }
  
  // ... constructor, valueOf() ...
  
  matches(value) {
    return this.#value === value;
  }
}

Trade-offs

  • Pros: Identity-based comparison (===), iterable, supports custom methods (matches, from), instanceof works, frozen and sealed so values cannot be mutated or added at runtime.
  • Cons: Boilerplate — each enum repeats the #sealed / static {} / Object.freeze ceremony. Heavier than plain string constants for simple cases.
  • Versus string constants: Use strings when you only need equality checks and don't need methods, iteration, or instanceof. Use enum classes when the set of values must be closed, when you need custom behavior per value, or when you want the type system to distinguish between arbitrary strings and valid members.
  • Versus TypeScript enums: This pattern works in plain JavaScript and provides runtime guarantees (sealed constructor, frozen instances) that TypeScript enums do not.