TypeScript Enums: When to Use Them (and When Not To)
A practical guide to typescript enum patterns — numeric, string, and const enums — plus when to replace them with union types. With working code examples.
FlowQL Team
AI Search Optimization Experts
TypeScript enums are one of the few features TypeScript adds to JavaScript that actually generates runtime code. That single fact explains most of the controversy around them — and most of the bugs they produce.
This guide covers every enum variant with working code examples, explains the cases where union types are a cleaner choice, and gives you a decision framework you can apply right now to your codebase.
What Is a TypeScript Enum?
A TypeScript enum is a named collection of constants. It's one of the original features added by TypeScript — predating many modern patterns — and it's still in the official TypeScript handbook. Unlike most TypeScript, enums compile to real JavaScript objects. That's both their superpower and their most common source of confusion.
// A basic numeric enum
enum Direction {
Up, // 0
Down, // 1
Left, // 2
Right, // 3
}
// Usage
const move = (dir: Direction) => {
if (dir === Direction.Up) {
console.log("Moving up");
}
};
move(Direction.Up); // ✅ fine
move(0); // ✅ also fine — numeric enums accept the raw number
move(99); // ✅ TypeScript won't catch this — a known footgun
The move(99) case is not a typo. TypeScript's numeric enum type is not restricted to declared values — any number is assignable to a numeric enum type. This surprises almost everyone the first time they encounter it.
The compiled output for this enum looks like:
// What TypeScript emits at runtime
var Direction;
(function (Direction) {
Direction[Direction["Up"] = 0] = "Up";
Direction[Direction["Down"] = 1] = "Down";
Direction[Direction["Left"] = 2] = "Left";
Direction[Direction["Right"] = 3] = "Right";
})(Direction || (Direction = {}));
That IIFE creates a bidirectional mapping: Direction.Up === 0 and Direction[0] === "Up". The reverse mapping is unique to numeric enums. String enums don't have it.
Numeric Enums vs String Enums
What's the difference between a numeric and string enum in TypeScript?
Numeric enums auto-increment their values starting from 0 (or from any starting value you set). String enums require you to explicitly assign a string value to every member. The practical difference is that string enums are safer and more debuggable — they don't have the numeric assignability hole, and their values appear as readable strings in logs, network requests, and debuggers.
// Numeric enum — values are 0, 1, 2
enum Status {
Pending,
Active,
Inactive,
}
// String enum — values are explicit strings
enum StatusString {
Pending = "pending",
Active = "active",
Inactive = "inactive",
}
// The safety difference in practice
function setStatus(s: Status) {}
setStatus(999); // ✅ TypeScript accepts this — no error!
function setStatusStr(s: StatusString) {}
setStatusStr("pending"); // ❌ Error: Argument of type '"pending"' is not assignable
// to parameter of type 'StatusString'
setStatusStr(StatusString.Pending); // ✅ Only enum member works
For anything that touches an API, database, or serialized format — use string enums if you use enums at all. "pending" in a log file is useful. 1 is not.
You can also set custom numeric starting values or mix manual and auto-incremented values:
// Custom starting value
enum HttpStatus {
OK = 200,
Created = 201,
BadRequest = 400,
Unauthorized = 401,
NotFound = 404,
InternalError = 500,
}
// Heterogeneous enum (allowed but rarely a good idea)
enum Mixed {
No = 0,
Yes = "YES",
}
Heterogeneous enums (mixing number and string members) are technically valid but almost always a sign that the design needs rethinking. They exist mainly for compatibility with older JavaScript patterns.
Const Enums: The Performant Option
What is a const enum in TypeScript?
A const enum is an enum that TypeScript inlines at compile time. Instead of emitting the IIFE object, TypeScript replaces every usage with its literal value — so no enum object exists in the output JavaScript at all. This eliminates the runtime overhead and bundle size of a regular enum, but it comes with a significant constraint: const enum is incompatible with isolatedModules, the flag required by Babel, esbuild, and SWC.
Since Next.js, Vite, and most modern build tools use one of those transpilers under the hood, const enums will break your build in most modern projects unless you configure a workaround.
// const enum — inlined at compile time
const enum Alignment {
Left = "left",
Center = "center",
Right = "right",
}
// This usage in source:
const align = Alignment.Center;
// Compiles to this (no enum object anywhere):
const align = "center";
If you're working in a project with "isolatedModules": true in your tsconfig.json (which Next.js sets by default), TypeScript will error if you try to use a const enum from another file. The TypeScript team acknowledges this tension and the practical recommendation for most teams is to avoid const enum unless you control the entire compilation pipeline with tsc directly.
The Case Against Enums: Use Union Types Instead
This is where most modern TypeScript style guides land, and for good reason. String literal union types cover the majority of enum use cases without the runtime overhead, the isolatedModules incompatibility, or the nominal typing friction.
// String enum version
enum Direction {
Up = "UP",
Down = "DOWN",
Left = "LEFT",
Right = "RIGHT",
}
// Union type version — identical semantics, zero runtime cost
type Direction = "UP" | "DOWN" | "LEFT" | "RIGHT";
// Both work the same in switch statements
function getLabel(dir: Direction): string {
switch (dir) {
case "UP": return "Move up";
case "DOWN": return "Move down";
case "LEFT": return "Move left";
case "RIGHT": return "Move right";
}
}
The union type compiles to nothing — it's erased completely. There's no runtime object, no bundle size impact, and no IIFE. The switch exhaustiveness checking works identically.
Matt Pocock, probably the best-known TypeScript educator right now, makes the case for preferring union types over enums clearly: enums introduce a new nominal type system on top of JavaScript's structural one, which creates friction at API boundaries and with third-party libraries.
For the cases where you do want the named-namespace feel of an enum but with union-type assignability, an as const object is a useful middle ground:
// Object const pattern — the enum alternative
const Direction = {
Up: "UP",
Down: "DOWN",
Left: "LEFT",
Right: "RIGHT",
} as const;
// Derive the type from the object
type Direction = (typeof Direction)[keyof typeof Direction];
// type Direction = "UP" | "DOWN" | "LEFT" | "RIGHT"
// Now you can use both the namespace and plain strings
function move(dir: Direction) {}
move(Direction.Up); // ✅ namespace access works
move("UP"); // ✅ plain string also works — unlike a string enum
This pattern gives you the autocomplete and readability of enum member access, the structural typing of a union, and zero runtime overhead beyond the object itself.
If you're running into TypeScript errors that you can't trace back to documentation — the kind AI tools generate confidently wrong answers for — a senior engineer on a 30-minute call can usually pinpoint exactly what's happening. TypeScript's enum-related type errors are a common one that trips people up for hours.
Enum vs Union Type: When Each Wins
| Feature | Numeric Enum | String Enum | const Enum | Union Type | as const Object |
|---|---|---|---|---|---|
| Runtime object | Yes | Yes | No | No | Yes |
| Reverse mapping | Yes | No | No | No | No |
| isolatedModules safe | Yes | Yes | No | Yes | Yes |
| Accepts raw string | No | No | No | Yes | Yes |
| Accepts raw number | Yes | No | No | No | No |
| Iterable at runtime | Yes | Yes | No | No | Yes |
| Bundle size | Adds IIFE | Adds IIFE | Zero | Zero | Small object |
| Works with JSON APIs | Awkward | Workable | No | Yes | Yes |
Use a string enum when:
- You're in a team that already uses enums consistently
- You want nominal typing (you explicitly don't want plain strings to be assignable)
- You're working in a library with a stable API where breaking changes are managed carefully
Use a union type or as const object when:
- You're building an app (not a library)
- Your values cross an API or serialization boundary
- You're using Babel, SWC, or esbuild (i.e., almost any modern project)
- You want to evolve the type over time without coupling consumers to an enum object
Use a numeric enum when:
- You're working with bit flags
- You need the reverse mapping for display purposes
- You're interfacing with a system that uses numeric constants (some ORMs, older REST APIs)
For most new TypeScript code written in 2025 and beyond, the default should be union types or the as const object pattern. See the TypeScript vs JavaScript comparison for context on where TypeScript's type system adds the most value in real projects.
Common Enum Errors and How to Fix Them
How do I convert a TypeScript enum to an array?
Converting an enum to an array is straightforward for string enums, but numeric enums have a catch because of their reverse mapping.
// String enum — Object.values() works directly
enum Color {
Red = "red",
Green = "green",
Blue = "blue",
}
const colorValues = Object.values(Color);
// ["red", "green", "blue"] ✅
// Numeric enum — reverse mapping means Object.values() returns BOTH
// the string keys AND numeric values
enum Priority {
Low,
Medium,
High,
}
const badAttempt = Object.values(Priority);
// ["Low", "Medium", "High", 0, 1, 2] ❌
// Correct approach: filter out numeric entries
const priorityNames = Object.values(Priority).filter(
(v): v is string => typeof v === "string"
);
// ["Low", "Medium", "High"] ✅
// Or grab the keys instead (same result for auto-incremented enums)
const priorityKeys = Object.keys(Priority).filter(k => isNaN(Number(k)));
// ["Low", "Medium", "High"] ✅
This is a common source of bugs when populating dropdowns or select inputs from an enum — developers use Object.values() expecting just the meaningful values and get the doubled array instead.
Error: Type 'string' is not assignable to type 'MyEnum'
This is the nominal typing friction that comes with string enums. The fix depends on whether you're receiving data from an external source or typing an internal value.
enum Theme {
Light = "light",
Dark = "dark",
}
// Common scenario: data from an API or localStorage
const storedTheme = localStorage.getItem("theme"); // type: string | null
// ❌ Error: Type 'string | null' is not assignable to type 'Theme'
const theme: Theme = storedTheme;
// Option 1: Cast (use when you trust the source)
const theme = storedTheme as Theme;
// Option 2: Validate with a type guard (safer)
function isTheme(value: unknown): value is Theme {
return Object.values(Theme).includes(value as Theme);
}
const theme = isTheme(storedTheme) ? storedTheme : Theme.Light;
If you find yourself writing type guards for every enum boundary, that's a signal the union type pattern would be a better fit — "light" | "dark" accepts plain strings natively.
See the Vercel ignore TypeScript errors guide for related situations where TypeScript errors surface at build time in ways that aren't immediately obvious from the local error.
Enum values not narrowing correctly in a switch statement
TypeScript should narrow an enum type in a switch — but there are edge cases where it doesn't, particularly with numeric enums and the default branch.
enum ResponseCode {
Success = 200,
NotFound = 404,
Error = 500,
}
function handle(code: ResponseCode) {
switch (code) {
case ResponseCode.Success:
return "ok";
case ResponseCode.NotFound:
return "missing";
case ResponseCode.Error:
return "failed";
// TypeScript requires a default here for numeric enums
// because any number is assignable to a numeric enum type
default:
const _: never = code; // ❌ Error! number is not never
return "unknown";
}
}
The never exhaustiveness check that works perfectly with union types breaks with numeric enums because any number is a valid numeric enum value. This is another reason string enums are safer than numeric enums when exhaustiveness checking matters.
If you're hitting narrowing issues in a Next.js App Router project, the Next.js hydration error guide covers related TypeScript narrowing patterns that surface in server/client component boundaries.
Enums and isolatedModules: the const enum build failure
If you're using const enum and see this error:
Cannot access ambient const enums when 'isolatedModules' is enabled.
You have three options:
- Switch to a regular
enum— loses the inlining optimization but fixes the build - Switch to a union type or
as constobject — best long-term choice - Use
preserveConstEnumsin your tsconfig — emits enum objects even forconst enum, which defeats the purpose but at least compiles
For most projects, option 2 is correct. The Next.js use client boundary error guide covers a similar class of "works in isolation but fails in the build" TypeScript issues if you're running into related compilation problems.
Declaring enums in .d.ts files (ambient enums)
Ambient enums (declared in .d.ts files with declare enum) behave differently from regular enums. They have no runtime representation at all — TypeScript assumes the object exists somewhere else. This is useful for typing third-party libraries but confusing when you accidentally put a regular enum in a declaration file.
// my-types.d.ts
declare enum LogLevel {
Debug = 0,
Info = 1,
Warn = 2,
Error = 3,
}
// Somewhere else in your code — this will fail at runtime
// because there's no actual LogLevel object, only the type
console.log(LogLevel.Info); // ❌ ReferenceError at runtime
If you need an enum to exist at runtime, it must be in a .ts file, not a .d.ts file. This trips up developers who are trying to share types across packages.
Conclusion
TypeScript enums are not bad — they're just specific. Numeric enums with their bidirectional mapping are genuinely useful for bit flags and legacy integrations. String enums are a reasonable choice in library code where nominal typing adds safety at API boundaries. const enum has one viable niche: pure tsc projects where you control the full compilation pipeline.
For everything else — apps, Next.js projects, anything using modern build tools — union types and the as const object pattern do the same job with less friction, better JSON compatibility, and no isolatedModules footguns.
The decision rule: start with a union type. Reach for a string enum only when you specifically want the nominal typing behavior (plain strings should not be assignable). Never use const enum in a project with "isolatedModules": true.
TypeScript type errors compound fast when enums are involved — a wrong-typed enum at a module boundary can produce cascading errors that look unrelated. If you're stuck on a type error that AI tools keep answering confidently but incorrectly, book a 30-minute session with a senior engineer at FlowQL. Enum and union type issues are some of the fastest to resolve in person — what takes hours of Stack Overflow searching is usually a 5-minute explanation with the right context.
Further reading:
Still blocked?
This fix didn't work for your setup? Get a senior engineer on your screen in 30 minutes — fixed or refunded.
Reserve My Spot →Get a senior engineer on your screen.
30-minute live screen-share sessions with a vetted senior dev. Fixed or fully refunded — no questions asked.
No spam. Just a heads-up when sessions open.
Related Articles
TypeScript vs JavaScript: Which Should You Use?
TypeScript vs JavaScript: understand the real differences, performance tradeoffs, and when to use each — so you can make the right call for your project.
React Hook Form Not Working? 5 Fixes (2026)
Struggling with react hook form errors? This guide fixes validation not triggering, TypeScript issues, Controller bugs, and broken resets in v7.
Fix: params are undefined in Next.js App Router Server Component
params undefined in your Next.js server component? Learn how to handle the async breaking change in Next.js 15 and properly await your dynamic route data.