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_CASEor allkebab-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 blindlyas-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
switchstatements (with anever-typed default branch) over enum values so the compiler flags any missed case as you add members later.
Practice Exercises
- Define a string enum
HttpMethodwith members forGET,POST,PUT, andDELETE, each valued as its uppercase HTTP verb string. Write a function that accepts aHttpMethodand logs whether the method is typically idempotent (GET,PUT,DELETEare idempotent;POSTis not). - Write a type guard function
isHttpMethod(value: string): value is HttpMethodfor the enum above, then use it to safely convert a raw string like\"post\".toUpperCase()into aHttpMethod, falling back toHttpMethod.GETif invalid. - Given
enum Suit { Hearts = \"H\", Diamonds = \"D\", Clubs = \"C\", Spades = \"S\" }, write a function that takes aSuitand returns its full lowercase name (e.g.\"H\"→\"hearts\") using an exhaustiveswitch, and predict what compiler error you’d get if you added a new member without updating theswitch.
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.
