TypeScript Enums and Value Types

Last update: 2021-02-20 06:14:56 +0800 +0800
Available translations: 日本語 / 中文(简体) / 中文(繁體)

A closed set can require a compile-time constraint, a runtime lookup object, a wire representation, or a domain object with validated behavior. TypeScript offers different representations because those requirements are not equivalent.

Separate Type Requirements from Runtime Identity #

A type such as:

ts
type Status = "pending" | "running" | "done";

exists only during type checking. JavaScript cannot enumerate Status, test whether an unknown string belongs to it, or attach metadata to it. Runtime code needs a corresponding value or validation function.

A native enum, by contrast, emits a JavaScript object and introduces an enum type. An as const table emits an ordinary object and derives types from it. A class emits a constructor and can enforce invariants. Choose according to which runtime identity the program actually needs.

Understand Native Enum Emission #

Numeric enums assign numbers and emit forward and reverse mappings:

ts
enum StatusCode {
  Pending,
  Running,
  Done,
}

console.log(StatusCode.Running); // 1
console.log(StatusCode[1]);      // "Running"

The generated object supports both name-to-number and number-to-name lookup. String enums emit only name-to-value properties:

ts
enum Status {
  Pending = "pending",
  Running = "running",
  Done = "done",
}

Since TypeScript 5.0, every enum member has a distinct member type, including members initialized from computed values. That allows narrowing and exhaustive switches, but it does not change the emitted object.

const enum asks TypeScript to inline member values and erase the enum:

ts
const enum Direction {
  Left,
  Right,
}

Inlining can conflict with isolated-module transpilers and with libraries whose published declarations outlive the exact enum values used to compile a consumer. Avoid exporting ambient const enum declarations across package boundaries unless the entire toolchain and versioning policy are controlled.

Use Literal Unions for Type-Only Sets #

Literal unions match JSON-shaped APIs without emitted enum machinery:

ts
type Status = "pending" | "running" | "done";

function label(status: Status): string {
  switch (status) {
    case "pending":
      return "Pending";
    case "running":
      return "Running";
    case "done":
      return "Done";
  }
}

Control-flow analysis narrows status in each branch. A union can also discriminate variants that carry different data:

ts
type Payment =
  | { kind: "card"; brand: string; lastFour: string }
  | { kind: "cash" }
  | { kind: "wire"; iban: string };

After payment.kind === "card", the compiler exposes brand and lastFour. The union checks code that already has a Payment; it does not validate an untrusted JSON value at runtime.

Derive Types from as const Tables #

A value table keeps runtime validation and the compile-time union tied to one definition:

ts
const Status = {
  Pending: "pending",
  Running: "running",
  Done: "done",
} as const satisfies Record<string, string>;

type StatusName = keyof typeof Status;
type Status = (typeof Status)[StatusName];

const statuses: ReadonlySet<string> = new Set(Object.values(Status));

function isStatus(value: unknown): value is Status {
  return typeof value === "string" && statuses.has(value);
}

as const prevents the property values from widening to string. satisfies checks the table’s shape without discarding those literal types. The read-only set intentionally accepts any string for lookup; the typeof check and set membership provide the runtime proof needed by the type predicate.

Use an array when order is primary:

ts
const statuses = ["pending", "running", "done"] as const;
type Status = (typeof statuses)[number];

Use an object when stable symbolic keys, metadata lookup, or compatibility with existing member access is primary.

Model Validated Values as Objects #

A value object represents a domain value whose invariant and behavior belong together. For example, a percentage is more than an arbitrary number:

ts
class Percentage {
  private constructor(readonly value: number) {}

  static from(value: number): Percentage {
    if (!Number.isFinite(value) || value < 0 || value > 100) {
      throw new RangeError("percentage must be finite and between 0 and 100");
    }
    return new Percentage(value);
  }

  asRatio(): number {
    return this.value / 100;
  }

  equals(other: Percentage): boolean {
    return this.value === other.value;
  }

  toJSON(): number {
    return this.value;
  }
}

The private constructor forces callers through validation. Methods name domain operations, and toJSON defines the boundary representation. Deserialization must call Percentage.from again because JSON parsing returns a number, not a class instance.

Do not use a class only to wrap a label. Allocation and conversion code are justified when the object protects an invariant, supplies behavior, or prevents mixing structurally similar primitives.

Design Serialization Boundaries Explicitly #

Wire formats should use stable, documented strings or numbers rather than depending on TypeScript’s emitted object shape. Numeric enum auto-increment values are fragile across reordered members; reverse mappings are an implementation detail, not a protocol.

At an input boundary:

  1. Parse the untrusted representation.
  2. Validate membership or construct a value object.
  3. Keep the validated type inside the application.
  4. Convert deliberately when writing JSON, database values, or messages.

Use a native enum when consumers need its emitted runtime identity and the toolchain accepts the output. Use a literal union for a type-only set, an as const table when code also needs runtime values, a discriminated union for variant-specific data, and a value object for validation plus behavior.

Comments

Read or comment on GitHub (sign-in required to comment)