TypeScript Enums

Enums in TypeScript provide a way to define a set of named constants. They allow you to create a collection of related values that can be used as a type. Enums make your code more readable, maintainable, and less prone to errors by giving meaningful names to sets of numeric or string values.

Enum Basics

Here's a basic enum definition in TypeScript:

enum Direction {
  North,
  East,
  South,
  West,
}

// Using the enum
let myDirection: Direction = Direction.North;
console.log(myDirection); // 0
console.log(Direction.East); // 1
console.log(Direction[0]); // "North"

In this example:

  • We define an enum called Direction with four values
  • By default, enum values are assigned incremental numeric values starting from 0
  • You can use the enum as a type annotation (let myDirection: Direction)
  • You can access the enum's numeric value or name using different syntaxes

Numeric Enums

By default, enums in TypeScript are numeric. The first value is assigned 0, and subsequent values are incremented by 1:

enum Status {
  Pending, // 0
  Processing, // 1
  Completed, // 2
  Failed, // 3
}

let orderStatus: Status = Status.Processing;
console.log(orderStatus); // 1

Custom Numeric Values

You can assign custom numeric values to enum members:

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

function handleResponse(status: HttpStatus) {
  if (status === HttpStatus.OK) {
    console.log("Request successful");
  } else if (status >= HttpStatus.BadRequest) {
    console.log("Request failed");
  }
}

handleResponse(HttpStatus.OK); // "Request successful"
handleResponse(HttpStatus.NotFound); // "Request failed"

When you assign a value to an enum member, subsequent members will be auto-incremented from that value:

enum Priority {
  Low = 5,
  Medium, // 6
  High, // 7
  Critical, // 8
}

Computed and Constant Members

Enum members can have constant values or computed values:

enum FileAccess {
  // Constant members
  None = 0,
  Read = 1 << 0, // 1 (bitshift operation)
  Write = 1 << 1, // 2
  ReadWrite = Read | Write, // 3 (bitwise OR)

  // Computed member
  Timestamp = Date.now(),
}

Constant members are evaluated at compile time, while computed members are evaluated at runtime.

String Enums

TypeScript also supports string enums, where each member has a string value:

enum Direction {
  North = "NORTH",
  East = "EAST",
  South = "SOUTH",
  West = "WEST",
}

let myDirection: Direction = Direction.North;
console.log(myDirection); // "NORTH"

String enums have better readability and debugging experience compared to numeric enums because the values are meaningful when inspected at runtime. However, they don't support reverse mapping (you can't access Direction["NORTH"]).

Heterogeneous Enums

TypeScript allows mixing string and numeric values in enums, though this is generally not recommended:

enum BooleanLikeHeterogeneousEnum {
  No = 0,
  Yes = "YES",
}

Best practice: Stick to either all numeric or all string values within a single enum.

Const Enums

The const keyword can be used with enums to improve performance:

const enum Direction {
  North,
  East,
  South,
  West,
}

let myDirection = Direction.North;

At compilation, const enums are completely removed and their values are inlined wherever they're used. In the JavaScript output, you'll see:

let myDirection = 0; // Direction.North

This results in more efficient code but prevents certain runtime operations like reverse mapping.

Ambient Enums

In declaration files, you can define ambient enums which describe existing enum types from libraries:

declare enum APIStatus {
  Ready,
  Loading,
  Error,
}

Ambient enums are used to declare the shape of enums defined elsewhere, often in external libraries.

Enum Member Types

Individual enum members can also serve as types. Let's look at an example:

enum ShapeKind {
  Circle,
  Square,
  Triangle,
}

interface Circle {
  kind: ShapeKind.Circle;
  radius: number;
}

interface Square {
  kind: ShapeKind.Square;
  sideLength: number;
}

let circle: Circle = {
  kind: ShapeKind.Circle,
  radius: 10,
};

// This would cause a type error:
// let invalidCircle: Circle = {
//   kind: ShapeKind.Square,  // Error: Type 'ShapeKind.Square' is not assignable to type 'ShapeKind.Circle'
//   radius: 10
// };

In this example, we're using specific enum members (ShapeKind.Circle and ShapeKind.Square) as types for the kind property, ensuring type safety.

Union Enums and Enum Member Types

Enums can be combined with union types to create powerful type constraints:

enum Status {
  Active,
  Inactive,
  Pending,
}

// Use a subset of enum values as a type
type AvailableStatus = Status.Active | Status.Pending;

function processUser(userId: string, status: AvailableStatus) {
  // Process only users with Active or Pending status
}

// Valid
processUser("123", Status.Active);
processUser("456", Status.Pending);

// Invalid - will not compile
// processUser("789", Status.Inactive);

Enums at Runtime

Enums exist at runtime as real objects. This is different from TypeScript's other type constructs (like interfaces), which are erased during compilation:

enum Direction {
  North,
  East,
  South,
  West,
}

// You can pass the enum as a parameter
function printEnum(enumObject: any) {
  Object.keys(enumObject)
    .filter((key) => !isNaN(Number(key)))
    .forEach((key) => {
      console.log(`${key}: ${enumObject[key]}`);
    });
}

printEnum(Direction);
// 0: "North"
// 1: "East"
// 2: "South"
// 3: "West"

This runtime presence allows for more dynamic operations with enums, but also increases the size of your generated JavaScript.

Practical Examples

Example 1: User Roles

enum UserRole {
  Admin = "ADMIN",
  Editor = "EDITOR",
  Viewer = "VIEWER",
  Guest = "GUEST",
}

interface User {
  id: string;
  name: string;
  role: UserRole;
}

function checkAccess(user: User, requiredRole: UserRole): boolean {
  // Simple role hierarchy check
  switch (user.role) {
    case UserRole.Admin:
      return true; // Admin has access to everything
    case UserRole.Editor:
      return requiredRole !== UserRole.Admin;
    case UserRole.Viewer:
      return requiredRole === UserRole.Viewer || requiredRole === UserRole.Guest;
    case UserRole.Guest:
      return requiredRole === UserRole.Guest;
    default:
      return false;
  }
}

const user: User = {
  id: "user123",
  name: "Alice",
  role: UserRole.Editor,
};

console.log(checkAccess(user, UserRole.Viewer)); // true
console.log(checkAccess(user, UserRole.Admin)); // false

Example 2: Application Settings with Flags

enum LogLevel {
  None = 0,
  Error = 1,
  Warning = 2,
  Info = 4,
  Debug = 8,
  All = Error | Warning | Info | Debug,
}

class Logger {
  private level: LogLevel;

  constructor(level: LogLevel) {
    this.level = level;
  }

  log(message: string, messageLevel: LogLevel): void {
    // Use bitwise AND to check if this level is enabled
    if (this.level & messageLevel) {
      const prefix = LogLevel[messageLevel] || "LOG";
      console.log(`[${prefix}] ${message}`);
    }
  }
}

// Create a logger that only shows errors and warnings
const logger = new Logger(LogLevel.Error | LogLevel.Warning);

logger.log("System starting up", LogLevel.Info); // Not logged
logger.log("Missing optional config", LogLevel.Warning); // Logged
logger.log("Failed to connect", LogLevel.Error); // Logged
logger.log("Data structure details", LogLevel.Debug); // Not logged

This example uses bit flags to create a combination of log levels.

Example 3: State Machine for Order Processing

enum OrderState {
  Created,
  Processing,
  Shipped,
  Delivered,
  Canceled,
}

class Order {
  private state: OrderState;

  constructor(
    public id: string,
    public customerName: string
  ) {
    this.state = OrderState.Created;
  }

  getState(): OrderState {
    return this.state;
  }

  getStateAsString(): string {
    return OrderState[this.state];
  }

  processOrder(): boolean {
    if (this.state === OrderState.Created) {
      this.state = OrderState.Processing;
      console.log(`Order ${this.id} is now being processed`);
      return true;
    }
    console.log(`Cannot process order ${this.id} in ${this.getStateAsString()} state`);
    return false;
  }

  shipOrder(): boolean {
    if (this.state === OrderState.Processing) {
      this.state = OrderState.Shipped;
      console.log(`Order ${this.id} has been shipped`);
      return true;
    }
    console.log(`Cannot ship order ${this.id} in ${this.getStateAsString()} state`);
    return false;
  }

  deliverOrder(): boolean {
    if (this.state === OrderState.Shipped) {
      this.state = OrderState.Delivered;
      console.log(`Order ${this.id} has been delivered`);
      return true;
    }
    console.log(`Cannot deliver order ${this.id} in ${this.getStateAsString()} state`);
    return false;
  }

  cancelOrder(): boolean {
    if (this.state !== OrderState.Delivered && this.state !== OrderState.Canceled) {
      this.state = OrderState.Canceled;
      console.log(`Order ${this.id} has been canceled`);
      return true;
    }
    console.log(`Cannot cancel order ${this.id} in ${this.getStateAsString()} state`);
    return false;
  }
}

// Usage
const order = new Order("ORD123", "John Doe");
console.log(`New order state: ${order.getStateAsString()}`); // "Created"

order.processOrder(); // "Order ORD123 is now being processed"
order.shipOrder(); // "Order ORD123 has been shipped"
order.deliverOrder(); // "Order ORD123 has been delivered"

// Try to cancel a delivered order
order.cancelOrder(); // "Cannot cancel order ORD123 in Delivered state"

This example demonstrates using enums to track the state of an order through its lifecycle.

Best Practices for Using Enums

1. Use PascalCase for enum names and enum members

// Good
enum HttpStatus {
  OK = 200,
  NotFound = 404,
}

// Not recommended
enum httpStatus {
  ok = 200,
  notFound = 404,
}

2. Use string enums for better readability

// Better - values are meaningful when debugging
enum Direction {
  North = "NORTH",
  East = "EAST",
  South = "SOUTH",
  West = "WEST",
}

// Less clear at runtime
enum Direction {
  North, // 0
  East, // 1
  South, // 2
  West, // 3
}

3. Use const enums for better performance when possible

// More efficient - values are inlined
const enum Direction {
  North,
  East,
  South,
  West,
}

// Regular enum creates runtime object
enum Direction {
  North,
  East,
  South,
  West,
}

4. Avoid using enum as a parameter type; use union of specific values instead

enum Status {
  Active,
  Inactive,
  Pending,
}

// Less desirable: allows any Status value
function processStatus(status: Status) {
  /* ... */
}

// Better: restricts to specific values
function processStatus(status: Status.Active | Status.Pending) {
  /* ... */
}

5. Be careful with computed values in enums

Computed values can lead to unexpected behavior and are evaluated at runtime rather than compile time. Use them only when necessary.

6. For bit flags, use powers of 2 and document the purpose

enum Permissions {
  None = 0, // 0000
  Read = 1 << 0, // 0001
  Write = 1 << 1, // 0010
  Execute = 1 << 2, // 0100
  All = Read | Write | Execute, // 0111
}

7. Consider alternatives to enums when appropriate

TypeScript offers other ways to represent a fixed set of values:

// Union of string literals
type Direction = "North" | "East" | "South" | "West";

// For constant values that need a namespace
const HttpStatus = {
  OK: 200,
  Created: 201,
  BadRequest: 400,
  NotFound: 404,
} as const;
type HttpStatus = (typeof HttpStatus)[keyof typeof HttpStatus];

These alternatives can sometimes offer better type safety or smaller compiled code.

Exercises

Exercise 1: Basic Enum

Create a DaysOfWeek enum with values for each day of the week. Then write a function that takes a day value and returns whether it's a weekday or weekend.

Exercise 2: String Enum with API Status Codes

Create a string enum to represent different API response statuses. Write a function that simulates an API call and returns a random status.

Exercise 3: Enum with Bit Flags

Create an enum to represent different user permissions using bit flags. Then write functions to check if a user has specific permissions and to add or remove permissions.

Summary

TypeScript enums provide a powerful way to define sets of named constants that improve code readability and maintainability:

  • Numeric enums: The default, where members have numeric values
  • String enums: Where members have string values for better readability
  • Const enums: For better performance by inlining values at compile time
  • Heterogeneous enums: Mixing string and numeric values (not recommended)
  • Computed members: Dynamic values determined at runtime
  • Bit flag enums: For representing combinations of flags

Key best practices:

  • Use PascalCase for enum names and members
  • Prefer string enums for better debugging
  • Use const enums for performance-critical code
  • Consider alternatives like union types when appropriate
  • Use powers of 2 for bit flags

Enums are a valuable tool in TypeScript's type system, helping you express intent more clearly and catch errors at compile time rather than runtime.