In the previous tutorial, we learned about union types and literal types. Now let’s learn about enums and const assertions — two ways to define a fixed set of values in TypeScript.

By the end of this tutorial, you will know when to use enums, when to use as const, and how the satisfies operator works.

What is an Enum?

An enum (enumeration) is a way to define a group of named constants. TypeScript has three kinds: numeric enums, string enums, and const enums.

Numeric Enums

By default, enums use numbers starting from 0:

enum Direction {
  Up,    // 0
  Down,  // 1
  Left,  // 2
  Right, // 3
}

let move: Direction = Direction.Up;
console.log(move);              // 0
console.log(Direction.Right);   // 3

You can set a starting value. The rest auto-increment:

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

console.log(HttpStatus.OK);       // 200
console.log(HttpStatus.NotFound); // 404

Reverse Mapping

Numeric enums have a special feature: reverse mapping. You can get the name from the number:

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

console.log(Direction[0]); // "Up"
console.log(Direction[2]); // "Left"

This works because TypeScript generates a JavaScript object that maps both ways: name to number AND number to name.

Warning: Reverse mapping only works with numeric enums, not string enums.

String Enums

String enums use string values instead of numbers:

enum Status {
  Active = "ACTIVE",
  Inactive = "INACTIVE",
  Pending = "PENDING",
}

console.log(Status.Active); // "ACTIVE"

String enums are more readable in logs and debugging. When you see "ACTIVE" in a log, you know what it means. When you see 0, you have to look up the enum.

Every member must have a string value. Unlike numeric enums, string enums do not auto-increment:

enum Color {
  Red = "RED",
  Green = "GREEN",
  // Blue, // Error: Enum member must have initializer
  Blue = "BLUE",
}

Using Enums in Functions

enum Role {
  Admin = "ADMIN",
  Editor = "EDITOR",
  Viewer = "VIEWER",
}

function checkAccess(role: Role): boolean {
  switch (role) {
    case Role.Admin:
      return true;
    case Role.Editor:
      return true;
    case Role.Viewer:
      return false;
  }
}

console.log(checkAccess(Role.Admin));  // true
console.log(checkAccess(Role.Viewer)); // false

Const Enums

A const enum is inlined at compile time. The enum object does not exist in the generated JavaScript:

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

let move = Direction.Up;

The compiled JavaScript is just:

let move = 0; // Direction.Up is replaced with 0

Const enums produce smaller bundles because the enum object is removed. But there are trade-offs:

  • No reverse mapping
  • Cannot iterate over members
  • Some bundlers (like esbuild) do not support const enums
  • Cannot be used in declaration files easily

When to use: Only when bundle size matters and you do not need runtime access to the enum object.

Enums vs Union Types

In Tutorial #6, we saw that union types can do similar things:

// Enum approach
enum StatusEnum {
  Active = "ACTIVE",
  Inactive = "INACTIVE",
  Pending = "PENDING",
}

// Union type approach
type StatusUnion = "ACTIVE" | "INACTIVE" | "PENDING";

Which should you choose? Here is a comparison:

FeatureEnumUnion Type
Runtime objectYes (can iterate)No
Bundle sizeLargerZero (erased)
Reverse mappingYes (numeric only)No
AutocompletionYesYes
RefactoringRename in one placeFind-and-replace
Library compatibilityMay cause issuesWorks everywhere

Modern TypeScript recommendation: Use union types for most cases. Use enums when you need runtime iteration or numeric reverse mapping.

Many large projects (including the TypeScript compiler itself) are moving away from enums toward union types and as const.

as const — Const Assertions

The as const assertion tells TypeScript to infer the narrowest possible type for a value:

// Without as const — type is string[]
const colors = ["red", "green", "blue"];
// Type: string[]

// With as const — type is readonly ["red", "green", "blue"]
const colorsConst = ["red", "green", "blue"] as const;
// Type: readonly ["red", "green", "blue"]

With as const:

  • Arrays become readonly tuples
  • Objects become readonly with literal types
  • All values are narrowed to their literal types

as const with Objects

const config = {
  apiUrl: "https://api.example.com",
  timeout: 5000,
  retries: 3,
} as const;

// Type: {
//   readonly apiUrl: "https://api.example.com";
//   readonly timeout: 5000;
//   readonly retries: 3;
// }

config.timeout = 10000; // Error: Cannot assign to 'timeout' because it is a read-only property

Creating an Enum-Like Pattern with as const

You can use as const to create something like an enum without the enum overhead:

const Status = {
  Active: "ACTIVE",
  Inactive: "INACTIVE",
  Pending: "PENDING",
} as const;

// Extract the values as a union type
type Status = (typeof Status)[keyof typeof Status];
// Type: "ACTIVE" | "INACTIVE" | "PENDING"

function setStatus(status: Status): void {
  console.log(`Status: ${status}`);
}

setStatus(Status.Active);  // OK
setStatus("ACTIVE");       // OK
setStatus("UNKNOWN");      // Error

This pattern gives you:

  • Named constants (Status.Active)
  • A union type ("ACTIVE" | "INACTIVE" | "PENDING")
  • Zero runtime overhead (the object is just a plain JavaScript object)
  • Full autocompletion in your editor

Many TypeScript developers prefer this pattern over enums.

The satisfies Operator

The satisfies operator (TypeScript 4.9+) validates that a value matches a type without widening the type:

type Color = "red" | "green" | "blue";

// Without satisfies — type annotation loses knowledge of exact keys
const palette: Record<string, Color> = {
  primary: "red",
  secondary: "blue",
};
// palette.primary is Color, and palette.anything is also valid

// With satisfies — validates shape but keeps exact literal types
const paletteConst = {
  primary: "red",
  secondary: "blue",
} satisfies Record<string, Color>;
// paletteConst.primary is "red" (literal type preserved, not widened to Color)
// paletteConst.unknown — Error: Property 'unknown' does not exist

The difference: with satisfies, TypeScript validates the shape but keeps knowledge of the exact keys and inferred types. Without it (using a type annotation), TypeScript widens the type and loses information about which keys exist.

Practical Example: Configuration

type Theme = {
  colors: Record<string, string>;
  fontSize: Record<string, number>;
};

const theme = {
  colors: {
    primary: "#3498db",
    secondary: "#2ecc71",
    danger: "#e74c3c",
  },
  fontSize: {
    small: 12,
    medium: 16,
    large: 24,
  },
} satisfies Theme;

// TypeScript knows the exact keys
console.log(theme.colors.primary);   // OK — TypeScript knows "primary" exists
console.log(theme.colors.warning);   // Error — "warning" does not exist
console.log(theme.fontSize.medium);  // OK — TypeScript knows it is 16

Without satisfies, using the Theme type annotation would lose the knowledge of exact keys like "primary" and "small".

satisfies vs Type Annotation vs as const

type Config = {
  port: number;
  host: string;
};

// Type annotation — widens the type
const config1: Config = { port: 3000, host: "localhost" };
// config1.port is number

// as const — narrows to literals, but does not validate shape
const config2 = { port: 3000, host: "localhost" } as const;
// config2.port is 3000 (literal), but no shape validation

// satisfies — validates shape AND keeps inferred types (not widened to Config)
const config3 = { port: 3000, host: "localhost" } satisfies Config;
// config3.port is number, shape is validated, exact keys are known

// as const + satisfies — best of both worlds
const config4 = { port: 3000, host: "localhost" } as const satisfies Config;
// config4.port is 3000 (literal) AND shape is validated

The as const satisfies Type combination is the most precise option.

Object.freeze vs as const

Both prevent modification, but they work differently:

// Object.freeze — runtime protection
const config1 = Object.freeze({ port: 3000, host: "localhost" });
config1.port = 4000; // Error at runtime AND compile time

// as const — compile-time only
const config2 = { port: 3000, host: "localhost" } as const;
config2.port = 4000; // Error at compile time only

Object.freeze prevents changes at runtime too. as const only prevents changes at compile time — at runtime, the object can still be changed (if you bypass TypeScript).

For most cases, as const is enough. Use Object.freeze when you need runtime safety.

Quick Practice

Create src/enums.ts and try these:

// String enum
enum LogLevel {
  Debug = "DEBUG",
  Info = "INFO",
  Warn = "WARN",
  Error = "ERROR",
}

function log(level: LogLevel, message: string): void {
  console.log(`[${level}] ${message}`);
}

log(LogLevel.Info, "Server started");
log(LogLevel.Error, "Connection failed");

// as const pattern (enum alternative)
const HttpMethod = {
  Get: "GET",
  Post: "POST",
  Put: "PUT",
  Delete: "DELETE",
} as const;

type HttpMethod = (typeof HttpMethod)[keyof typeof HttpMethod];

function sendRequest(method: HttpMethod, url: string): void {
  console.log(`${method} ${url}`);
}

sendRequest(HttpMethod.Get, "/api/users");
sendRequest("POST", "/api/users"); // Also works

// satisfies example
type AppConfig = {
  name: string;
  port: number;
  debug: boolean;
};

const config = {
  name: "my-app",
  port: 3000,
  debug: true,
} as const satisfies AppConfig;

console.log(`${config.name} running on port ${config.port}`);

Run it:

npx tsx src/enums.ts

Output:

[INFO] Server started
[ERROR] Connection failed
GET /api/users
POST /api/users
my-app running on port 3000

What is Next?

In the next tutorial, we will learn about type narrowing and type guards — how to make TypeScript understand exactly what type a value is at any point in your code.