TypeScript String Enums

A string enum is a TypeScript enum whose members are initialized with string literal values instead of auto-incrementing numbers. String enums give you the same named-constant convenience as regular enums, but with values that are self-descriptive when logged, serialized, or inspected in a debugger — instead of a mysterious 0 or 2, you see \"Success\" or \"Pending\". This makes them a popular choice for representing a fixed set of string-based states, statuses, or categories.

Overview / How it works

By default, a TypeScript numeric enum assigns each member an incrementing number starting at 0 (or wherever you start it). That’s compact, but it means the runtime value carries no information about what it represents — if you console.log a numeric enum value, you just see a number. A string enum fixes this by requiring every member to be explicitly initialized with a string literal. Because every member must have its own literal (there’s no auto-increment for strings), string enums are fully explicit by construction — there’s no ambiguity about what value a member holds.

Under the type system, each member of a string enum is treated as its own string literal type, and the enum type itself is the union of those literal types. That means a variable typed as the enum can only be assigned one of the enum’s declared members (or the enum access itself) — not an arbitrary string, even one that happens to match a member’s value. This is different from numeric enums, which allow silent assignment from any number due to a longstanding compatibility exception in the type checker.

At runtime, a string enum compiles to a plain JavaScript object mapping each member name to its string value. Unlike numeric enums, string enums do not get a reverse mapping (value → name) generated for them, because a reverse mapping would only make sense if all values were unique in a predictable way and, more importantly, because the reverse-mapping trick numeric enums use (storing both directions in the same object) doesn’t fit strings without overwriting keys. Types themselves are always erased at compile time — the compiled JavaScript for an enum’s members has no leftover : string annotations, just the object literal.

Syntax

enum StatusCode {
  Success = "SUCCESS",
  NotFound = "NOT_FOUND",
  ServerError = "SERVER_ERROR",
}
  • enum — the keyword that declares an enum type.
  • StatusCode — the enum’s name; it becomes both a type and a runtime object.
  • Each member (Success, NotFound, …) — must be assigned an explicit string literal; there is no default or auto-increment.
  • = "SUCCESS" — the string value backing that member at runtime, and also its literal type.

You reference a member via dot notation, e.g. StatusCode.Success, and its compile-time type is StatusCode.Success (a specific literal within the StatusCode union), while the general enum type StatusCode is the union of all its members.

Examples

Example 1: A basic string enum for order status

enum OrderStatus {
  Pending = "PENDING",
  Shipped = "SHIPPED",
  Delivered = "DELIVERED",
  Cancelled = "CANCELLED",
}

function describeOrder(status: OrderStatus): string {
  switch (status) {
    case OrderStatus.Pending:
      return "Order is being prepared.";
    case OrderStatus.Shipped:
      return "Order is on its way.";
    case OrderStatus.Delivered:
      return "Order has arrived.";
    case OrderStatus.Cancelled:
      return "Order was cancelled.";
  }
}

console.log(describeOrder(OrderStatus.Shipped));
console.log(OrderStatus.Pending);

Output:

Order is on its way.
PENDING

Each case narrows status to a specific literal type, and logging the enum member directly prints its underlying string, not a numeric index — this is the main readability win over numeric enums.

Example 2: String enums are NOT interchangeable with matching string literals

enum Direction {
  Up = "UP",
  Down = "DOWN",
  Left = "LEFT",
  Right = "RIGHT",
}

function move(direction: Direction): void {
  console.log(`Moving ${direction}`);
}

move(Direction.Up);

const fromApi: string = "UP";
// move(fromApi); // Error: 'string' is not assignable to parameter of type 'Direction'

move(fromApi as Direction);

Output:

Moving UP
Moving UP

Even though fromApi holds the exact string \"UP\", TypeScript refuses to pass it where a Direction is expected because a plain string is a much wider type than the enum’s closed set of literals. This is a deliberate safety feature: it stops arbitrary strings (say, a typo like \"Up\" with different casing) from silently satisfying an enum-typed parameter. To bridge external data (e.g. JSON from an API) into an enum, you either assert with as Direction after validating the value, or write a small runtime check.

Example 3: Validating unknown input against a string enum

enum LogLevel {
  Debug = "debug",
  Info = "info",
  Warn = "warn",
  Error = "error",
}

function isLogLevel(value: string): value is LogLevel {
  return Object.values(LogLevel).includes(value as LogLevel);
}

function parseLogLevel(raw: string): LogLevel {
  if (isLogLevel(raw)) {
    return raw;
  }
  return LogLevel.Info;
}

console.log(parseLogLevel("warn"));
console.log(parseLogLevel("nonsense"));

Output:

warn
info

This is the standard pattern for taking untyped input (from a config file, query string, or HTTP body) and safely converting it into your string enum type. Object.values(LogLevel) works at runtime because a string enum compiles to a plain object whose values are the strings themselves — there’s no numeric reverse-mapping noise to filter out, unlike with numeric enums.

Under the hood

When tsc compiles a string enum, each member becomes a straightforward property assignment on an object with the enum’s name. Roughly, enum Direction { Up = \"UP\" } compiles to JavaScript equivalent to an object { Up: \"UP\" } assigned to a variable named Direction (wrapped so the object can’t be reassigned). There is no companion reverse-mapping object as there is for numeric enums, and there is no leftover type information anywhere in the emitted code — all type checking (member narrowing in switch, rejecting a bare string argument, exhaustiveness checks) happens purely at compile time and disappears once the code is compiled. This is the general TypeScript rule: types are erased, values are not.

At the type level, the checker treats Direction.Up as its own singleton literal type, nested inside the union type Direction. That’s why a switch over all members lets the compiler narrow the type on each branch, and why (with --strict and a function returning a value on every branch, or a default) you can catch a forgotten case with an exhaustiveness check using the never type.

Common Mistakes

Mistake 1: Assuming numbers and strings can mix seamlessly in one enum

enum Mixed {
  A = "A",
  B = 1,
}

This actually compiles — TypeScript allows "heterogeneous" enums mixing string and numeric members — but it’s considered a mistake because it destroys most of the benefits of a string enum: the numeric member has no auto-increment relationship with anything, and consumers of Mixed now have to handle both a number and a string. The fix is to keep an enum consistently one type or the other.

enum Fixed {
  A = "A",
  B = "B",
}

Mistake 2: Trying to assign a matching string literal directly

enum Theme {
  Light = "light",
  Dark = "dark",
}

function applyTheme(theme: Theme): void {
  console.log(`Applying ${theme} theme`);
}

applyTheme("light");

This is rejected by tsc with an error like Argument of type '\"light\"' is not assignable to parameter of type 'Theme', because a plain string literal type is not automatically considered the same as an enum member, even with an identical value. The corrected call uses the enum member itself:

enum Theme2 {
  Light = "light",
  Dark = "dark",
}

function applyTheme2(theme: Theme2): void {
  console.log(`Applying ${theme} theme`);
}

applyTheme2(Theme2.Light);

Mistake 3: Forgetting there’s no reverse mapping

enum Color {
  Red = "RED",
  Blue = "BLUE",
}

// const name = Color["RED"]; // Error: Element implicitly has an 'any' type
// because 'RED' is treated as a value, not a member name, and string enums
// don't generate a value-to-name reverse lookup like numeric enums do.

const name = Color.Red;
console.log(name);

Output:

RED

With numeric enums, indexing by the numeric value (e.g. Color[0]) returns the member name because the compiler emits a reverse mapping. String enums don’t have this, so always look values up by member name (Color.Red), not by their string value.

Best Practices

  • Use string enums when the runtime value itself needs to be human-readable — logs, API payloads, URL query parameters, database columns.
  • Keep every member’s string value explicit and in a single consistent casing convention (e.g. all SCREAMING_SNAKE_CASE or all kebab-case) so consumers can predict the wire format.
  • Never mix string and numeric members in the same enum ("heterogeneous" enums) — it defeats the purpose of choosing a string enum.
  • When ingesting untyped data (JSON, form input, query strings), validate it against Object.values(YourEnum) or a dedicated type guard before treating it as the enum type — never blindly as-cast unchecked input.
  • If you don’t need a runtime object at all — only a closed set of string values for type-checking — consider a union of string literals (e.g. type Direction = \"UP\" | \"DOWN\") instead; it avoids the extra runtime object entirely and integrates more naturally with plain JSON.
  • Use exhaustive switch statements (with a never-typed default branch) over enum values so the compiler flags any missed case as you add members later.

Practice Exercises

  • Define a string enum HttpMethod with members for GET, POST, PUT, and DELETE, each valued as its uppercase HTTP verb string. Write a function that accepts a HttpMethod and logs whether the method is typically idempotent (GET, PUT, DELETE are idempotent; POST is not).
  • Write a type guard function isHttpMethod(value: string): value is HttpMethod for the enum above, then use it to safely convert a raw string like \"post\".toUpperCase() into a HttpMethod, falling back to HttpMethod.GET if invalid.
  • Given enum Suit { Hearts = \"H\", Diamonds = \"D\", Clubs = \"C\", Spades = \"S\" }, write a function that takes a Suit and returns its full lowercase name (e.g. \"H\"\"hearts\") using an exhaustive switch, and predict what compiler error you’d get if you added a new member without updating the switch.

Summary

  • String enums require every member to have an explicit string literal value — there’s no auto-increment for strings.
  • They compile to a plain JS object with no reverse (value-to-name) mapping, unlike numeric enums.
  • A plain string, even with a matching value, is not automatically assignable to a string enum type — you must use the enum member or an explicit assertion after validation.
  • Types are fully erased at compile time; only the object literal and its string values remain at runtime.
  • For simple closed sets of string values with no need for a runtime object, a string literal union type is often a lighter-weight alternative to a string enum.