TypeScript

TypeScript Enums at Runtime: When to Choose Them Over String Unions

Compare TypeScript string unions and enums through switches, numeric reverse lookup, and validation at application boundaries.

Editorial illustration for TypeScript Enums at Runtime: When to Choose Them Over String Unions

If callers only need to choose from a few known strings, start with a string-literal union. It restricts assignments during type checking without creating a JavaScript object. Choose a regular enum when named members are useful and code needs to access the enum at runtime. Regular enums are real objects; a union is only a type-level constraint. The TypeScript handbook describes enums as runtime objects, while this comparison of enums and types makes the runtime-object distinction explicit.

For values stored in a database or sent through an API, give enum members explicit string values. A numeric enum can assign numbers automatically, so its values depend on member order unless you specify them. Explicit strings make the intended external values visible in the declaration. That still does not make incoming data safe: neither an enum annotation nor a union checks a parsed value at runtime.

A string enum in a switch

Suppose an application receives job states. The enum gives the code named cases and assigns each one a string value:

enum State {
  Queued = "QUEUED",
  Running = "RUNNING",
  Done = "DONE",
}

function describeState(state: State): string {
  switch (state) {
    case State.Queued:
      return "Waiting to start";
    case State.Running:
      return "Work in progress";
    case State.Done:
      return "Finished";
    default:
      return "Unexpected state";
  }
}

console.log(State.Queued); // "QUEUED"
console.log(describeState(State.Running)); // "Work in progress"

// const invalidState: State = "CANCELLED"; // Type-checking error

State.Queued evaluates to its assigned string. The parameter type makes an invalid assignment visible to TypeScript; it does not control every value that might reach the function when JavaScript runs. The default branch gives an unexpected runtime value a fallback response, but a fallback is not an input-validation policy.

If the state comes from JSON, a request, or a database field that has not been checked, keep it as unknown until a runtime comparison succeeds. The enum object provides values to compare against:

function isState(value: unknown): value is State {
  return typeof value === "string" &&
    Object.values(State).includes(value as State);
}

function describeIncomingState(value: unknown): string {
  if (!isState(value)) {
    return "Invalid state";
  }

  return describeState(value);
}

Here, value as State only allows the comparison to be expressed to the checker. It is Object.values(State).includes(...) that checks membership at runtime. The type predicate lets subsequent TypeScript code use the result of that check; writing a type predicate alone would not validate anything. A string-enum validation example uses the same runtime-values approach.

What numeric reverse mapping adds

Numeric enums have another runtime capability. Without an initializer, members start at zero and increment; with an initializer, subsequent members increment from it. Numeric enums also support looking up a member name by its number:

enum Direction {
  Up,    // 0
  Down,  // 1
  Left,  // 2
}

console.log(Direction.Up); // 0
console.log(Direction[0]); // "Up"
console.log(State.Queued); // "QUEUED"

Direction[0] finds the name "Up". State.Queued instead reads a string enum member's value. String enums do not provide the corresponding value-to-name reverse mapping: knowing "QUEUED" does not give you a generated reverse entry for Queued. The distinction matters if you are choosing an enum specifically to look up names from stored values. The numeric-enum examples here show both automatic numbering and reverse lookup, and also distinguish string enums from numeric ones on reverse mapping.

When the union is enough

The same job states can be represented without an enum object:

type StateValue = "QUEUED" | "RUNNING" | "DONE";

let current: StateValue = "QUEUED";
current = "RUNNING";
// current = "CANCELLED"; // Type-checking error

This is useful when callers pass strings and no code needs State.Queued or another runtime enum lookup. The union restricts assignments TypeScript can check, but StateValue is not an object you can enumerate or index while the program runs. An invalid string read from outside the typed code still needs a runtime check; a successful type check of the example says nothing about that input. This union example illustrates the assignment constraint without an added JavaScript enum object.

The practical question is not whether the values form a fixed set—both representations express that to TypeScript. Ask whether the running application needs a shared object of named values or numeric name lookups. If not, use the union and validate external input separately. If it does, use a regular enum, choose explicit values for external contracts, and still check data at the boundary.

Find a note

Search by topic, title, or keyword.