TypeScript Enums: Numeric, String, and Const

Enums solve a fundamental problem in software development: managing magic numbers and strings scattered throughout your codebase. Instead of writing `if (userRole === 2)` or `status === 'PENDING'`,...

Key Insights

  • Numeric enums provide reverse mapping and auto-increment by default, but string enums offer better debugging and serialization at the cost of larger bundle sizes
  • Const enums eliminate runtime overhead by inlining values at compile time, but they break when consumed across module boundaries or with isolatedModules
  • Modern TypeScript alternatives like string literal unions with as const objects often provide better type safety and smaller bundles than traditional enums

Introduction to TypeScript Enums

Enums solve a fundamental problem in software development: managing magic numbers and strings scattered throughout your codebase. Instead of writing if (userRole === 2) or status === "PENDING", enums let you write self-documenting code like if (userRole === UserRole.Admin) or status === OrderStatus.Pending.

Here’s the difference in practice:

// Without enums - unclear and error-prone
function checkAccess(role: number): boolean {
  return role === 2 || role === 3;
}

const userRole = 1; // What does 1 mean?
checkAccess(userRole);

// With enums - self-documenting and type-safe
enum UserRole {
  Guest,
  User,
  Admin,
  SuperAdmin
}

function checkAccess(role: UserRole): boolean {
  return role === UserRole.Admin || role === UserRole.SuperAdmin;
}

const userRole = UserRole.User;
checkAccess(userRole); // Clear intent, compile-time safety

TypeScript provides three main enum variants: numeric, string, and const enums. Each has distinct characteristics that make them suitable for different scenarios.

Numeric Enums

Numeric enums are TypeScript’s default enum type. They auto-increment starting from 0 unless you specify otherwise, and they provide a unique feature: reverse mapping.

enum HttpStatus {
  OK = 200,
  Created = 201,
  BadRequest = 400,
  Unauthorized = 401,
  NotFound = 404,
  InternalServerError = 500
}

// Forward mapping
console.log(HttpStatus.OK); // 200

// Reverse mapping - get the name from the value
console.log(HttpStatus[200]); // "OK"

Auto-incrementing works even with custom starting points:

enum Priority {
  Low = 1,
  Medium, // 2
  High,   // 3
  Critical // 4
}

enum LogLevel {
  Error,   // 0
  Warning, // 1
  Info,    // 2
  Debug    // 3
}

The reverse mapping feature exists because numeric enums compile to a bidirectional object:

// Compiled JavaScript for HttpStatus enum
var HttpStatus;
(function (HttpStatus) {
    HttpStatus[HttpStatus["OK"] = 200] = "OK";
    HttpStatus[HttpStatus["Created"] = 201] = "Created";
    HttpStatus[HttpStatus["BadRequest"] = 400] = "BadRequest";
    // ... etc
})(HttpStatus || (HttpStatus = {}));

// Results in an object like:
// { 
//   OK: 200, Created: 201, BadRequest: 400,
//   200: "OK", 201: "Created", 400: "BadRequest"
// }

This bidirectional mapping is useful for logging and debugging, but it comes at a cost: larger bundle size and runtime overhead.

String Enums

String enums require explicit initialization for every member, but they offer significant advantages for debugging and serialization. When you log a string enum value, you see the actual string—not a number that requires mental mapping.

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

function processOrder(status: OrderStatus): void {
  switch (status) {
    case OrderStatus.Pending:
      console.log("Awaiting payment confirmation");
      break;
    case OrderStatus.Processing:
      console.log("Preparing order for shipment");
      break;
    case OrderStatus.Shipped:
      console.log("Order in transit");
      break;
    case OrderStatus.Delivered:
      console.log("Order completed");
      break;
    case OrderStatus.Cancelled:
      console.log("Order cancelled");
      break;
  }
}

// When debugging or logging
console.log(OrderStatus.Pending); // "PENDING" - immediately clear

String enums compile to simpler JavaScript without reverse mapping:

var OrderStatus;
(function (OrderStatus) {
    OrderStatus["Pending"] = "PENDING";
    OrderStatus["Processing"] = "PROCESSING";
    OrderStatus["Shipped"] = "SHIPPED";
    OrderStatus["Delivered"] = "DELIVERED";
    OrderStatus["Cancelled"] = "CANCELLED";
})(OrderStatus || (OrderStatus = {}));

String enums excel when working with APIs or databases where you need predictable, readable values. They’re also safer for serialization because the values remain meaningful when converted to JSON.

Const Enums

Const enums are performance-optimized enums that get completely erased during compilation. TypeScript inlines their values directly at usage sites, eliminating runtime overhead entirely.

const enum Direction {
  Up,
  Down,
  Left,
  Right
}

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

move(Direction.Up);
move(Direction.Right);

This compiles to:

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

move(0 /* Direction.Up */);
move(3 /* Direction.Right */);

The enum completely disappears—only the literal values remain. This is ideal for performance-critical code or when you want zero runtime footprint.

However, const enums have significant limitations:

const enum Color {
  Red = "RED",
  Green = "GREEN",
  Blue = "BLUE"
}

// This works
const myColor = Color.Red;

// This DOESN'T work - no reverse mapping
// const colorName = Color["RED"]; // Error

// This DOESN'T work across module boundaries with isolatedModules
// export const enum fails in certain build configurations

Const enums break with Babel, isolatedModules, and when publishing libraries. If you’re building a library or using modern build tools, avoid const enums.

Heterogeneous Enums and Best Practices

TypeScript technically allows mixing numeric and string values in a single enum, but this is almost always a bad idea:

// Don't do this
enum Confused {
  No = 0,
  Yes = "YES",
  Maybe = 1
}

Heterogeneous enums sacrifice clarity and create confusion. Stick to either all numeric or all string values.

Here are practical best practices for enum usage:

Use enums as function parameters for type safety:

enum Environment {
  Development = "development",
  Staging = "staging",
  Production = "production"
}

interface Config {
  apiUrl: string;
  environment: Environment;
  debugMode: boolean;
}

function initializeApp(config: Config): void {
  if (config.environment === Environment.Production) {
    config.debugMode = false;
  }
  // TypeScript ensures you can't pass invalid environment strings
}

Use string enums for values that cross system boundaries:

enum ApiErrorCode {
  InvalidCredentials = "INVALID_CREDENTIALS",
  RateLimitExceeded = "RATE_LIMIT_EXCEEDED",
  ResourceNotFound = "RESOURCE_NOT_FOUND"
}

// These values stay consistent in logs, databases, and API responses

Use numeric enums for internal state that doesn’t need serialization:

enum ComponentState {
  Initializing,
  Ready,
  Loading,
  Error
}

Alternatives and When Not to Use Enums

Modern TypeScript offers alternatives that often work better than enums. String literal unions provide similar type safety without runtime overhead:

type OrderStatus = "PENDING" | "PROCESSING" | "SHIPPED" | "DELIVERED";

function updateOrder(status: OrderStatus): void {
  // Full autocomplete and type checking
  console.log(status);
}

updateOrder("PENDING"); // Works
// updateOrder("INVALID"); // Error

For cases where you need both types and values, use as const:

const OrderStatus = {
  Pending: "PENDING",
  Processing: "PROCESSING",
  Shipped: "SHIPPED",
  Delivered: "DELIVERED"
} as const;

type OrderStatus = typeof OrderStatus[keyof typeof OrderStatus];

function processOrder(status: OrderStatus): void {
  if (status === OrderStatus.Pending) {
    // Type-safe access to both the object and the type
  }
}

This approach gives you:

  • Smaller bundle size (just a plain object)
  • Full type safety
  • Runtime access to values
  • Better tree-shaking

However, enums still have valid use cases. Use string enums when you need:

  • Explicit grouping of related constants
  • Exhaustiveness checking in switch statements
  • Integration with older codebases using enums

Use numeric enums when you need reverse mapping or working with bit flags. Avoid const enums unless you’re certain about your build pipeline and not publishing a library.

The best choice depends on your specific requirements. For new projects, start with string literal unions and as const objects. Reach for enums when you need their specific features, not out of habit.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.