Skip to lesson

learningtypescript.org / intermediate / 16-enums-and-constants · lesson 7 of 25

TL;DR

Learn TypeScript enums and const assertions to create named, type-safe constants. Replace magic strings and numbers in your code.

Key concepts

  • TypeScript enums
  • const assertions TypeScript
  • TypeScript constants
  • enum vs const assertion

Enums And Constants

Hardcoded strings and magic numbers are a maintenance nightmare. What does status === 2 mean? Is "ADMIN" spelled correctly everywhere? TypeScript gives you two powerful tools to solve this: enums and const assertions. Both let you define a fixed set of named values that are easy to read, impossible to misspell, and fully type-checked.

What You Already Know

By the end of this lesson you will give the tracker's items a closed vocabulary: a status drawn from an as const object and a priority drawn from a numeric enum, plus the code that lists every status and compares against a named constant instead of a loose string. You will also be able to say what each of those two constructs leaves behind in the emitted JavaScript, which is the thing that actually decides between them.

This is the second half of the domain-model milestone. Interfaces and Types gave each tracker entity its fields; this lesson gives the fields that hold a fixed choice their permitted values, so a status can never be a typo. You arrive already able to write a union of literal strings by hand — Data Structures had you storing exactly these tag values in collections, and Interfaces and Types named unions as one of the things a type alias can be. What is new is deriving that union from a real runtime object, so the values and the type can never drift apart.

Numeric Enums

The simplest enum assigns auto-incrementing numbers to each member, starting at zero. This is useful when you care about ordering or need to compare values, but the raw number doesn't need to mean anything to a human.

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

function move(direction: Direction): string {
  switch (direction) {
    case Direction.Up:    return "Moving up!";
    case Direction.Down:  return "Moving down!";
    case Direction.Left:  return "Moving left!";
    case Direction.Right: return "Moving right!";
  }
}

console.log(move(Direction.Up));    // "Moving up!"
console.log(Direction.Up);          // 0
console.log(Direction[0]);          // "Up"  ← reverse mapping

// You can also set custom starting values
enum HttpStatus {
  OK = 200,
  Created = 201,
  BadRequest = 400,
  Unauthorized = 401,
  NotFound = 404,
  InternalServerError = 500,
}

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

Notice the reverse mapping on line 12: numeric enums let you look up a name by its number (Direction[0] returns "Up"). This can be handy for debugging, but it also means the compiled JavaScript includes extra code.

Predict

A numeric enum mixes explicit and implicit values. Auto-increment fills each un-valued member by adding one to the member before it. Predict all three logged lines before running.

enum Level {
Low = 1,
Medium,      // no value given
High,        // no value given
Critical = 10,
Fatal,       // no value given
}

console.log(Level.Medium);
console.log(Level.High);
console.log(Level.Fatal);
Continue learning

String Enums

String enums are more common in real-world TypeScript because they produce readable values at runtime. When you log a string enum to the console or send it over an API, you see "ACTIVE" rather than 1.

enum OrderStatus {
  Pending   = "PENDING",
  Confirmed = "CONFIRMED",
  Shipped   = "SHIPPED",
  Delivered = "DELIVERED",
  Cancelled = "CANCELLED",
}

interface Order {
  id: number;
  item: string;
  status: OrderStatus;
}

function describeOrder(order: Order): string {
  switch (order.status) {
    case OrderStatus.Pending:
      return `Order #${order.id} is waiting to be confirmed.`;
    case OrderStatus.Confirmed:
      return `Order #${order.id} has been confirmed!`;
    case OrderStatus.Shipped:
      return `Order #${order.id} is on its way.`;
    case OrderStatus.Delivered:
      return `Order #${order.id} has been delivered. Enjoy!`;
    case OrderStatus.Cancelled:
      return `Order #${order.id} was cancelled.`;
  }
}

const order: Order = { id: 42, item: "TypeScript Handbook", status: OrderStatus.Shipped };
console.log(describeOrder(order));
// "Order #42 is on its way."

console.log(order.status);
// "SHIPPED"  ← readable at runtime

Unlike numeric enums, string enums do not have reverse mappings, which results in smaller compiled output. For most applications, string enums are the safer, more readable choice.

Const Assertions

Sometimes you want a plain object to behave like an enum — immutable values with type inference. The as const assertion freezes an object or array so TypeScript treats every value as a literal type instead of a general string or number. The typeof Theme[keyof typeof Theme] idiom below is a preview — it collects the value types of a const object into a union; you'll learn keyof properly in the Generics lesson. Read it for now; you'll build it later.

const Theme = {
  Light:  "light",
  Dark:   "dark",
  System: "system",
} as const;

// Extract the union of all value types
type ThemeMode = typeof Theme[keyof typeof Theme];
// ThemeMode = "light" | "dark" | "system"

function applyTheme(mode: ThemeMode): void {
  console.log(`Applying theme: ${mode}`);
}

applyTheme(Theme.Dark);    // OK
applyTheme("light");       // Also OK — literal matches
// applyTheme("purple");   // Error: not assignable to ThemeMode

// Works on arrays too
const SUPPORTED_LOCALES = ["en", "fr", "de", "ja"] as const;
type Locale = typeof SUPPORTED_LOCALES[number];
// Locale = "en" | "fr" | "de" | "ja"

const userLocale: Locale = "fr";
console.log(`User locale: ${userLocale}`);
console.log(`Supported: ${SUPPORTED_LOCALES.join(", ")}`);

The pattern typeof Obj[keyof typeof Obj] is a common idiom in TypeScript for extracting a union type from a const object's values. Keep it in your toolbox.

What the derived union actually buys you (a no-run demo)

The commented-out applyTheme("purple") line above is the whole point of deriving ThemeMode — but a comment cannot show you the error. This fence is marked no-run because its only job is to fail to compile: the runner strips the types and would run the bad call happily, which would contradict the lesson.

const Theme = {
  Light:  "light",
  Dark:   "dark",
  System: "system",
} as const;

type ThemeMode = typeof Theme[keyof typeof Theme];

function applyTheme(mode: ThemeMode): void {
  console.log(`Applying theme: ${mode}`);
}

applyTheme("purple");

Under tsc --strict this fails with:

error TS2345: Argument of type '"purple"' is not assignable to parameter of type 'ThemeMode'.

Now the runtime shadow — what the same mistake does when nothing type-checks it. Here applyTheme also looks the mode up in a duration table, exactly as real code would:

const Theme = {
  Light:  "light",
  Dark:   "dark",
  System: "system",
} as const;

type ThemeMode = typeof Theme[keyof typeof Theme];

const DURATION: Record<ThemeMode, number> = { light: 200, dark: 200, system: 0 };

function applyTheme(mode: ThemeMode): void {
  console.log(`Applying theme: ${mode} over ${DURATION[mode]}ms`);
}

applyTheme("purple" as ThemeMode);

That prints Applying theme: purple over undefinedms and exits successfully. Nothing crashes; the bad value simply flows through and the table lookup quietly returns undefined. The compile error is the only place this gets caught, which is why deriving the union is worth the typeof/keyof ceremony.

Recall

Without scrolling up: the as const trick above derives the type 'light' | 'dark' | 'system' from an object's values. In Interfaces and Types you could already write that exact type by hand, without any object. How, and what is that kind of type called?

Continue learning

Enums vs Const Assertions: When to Use Each

Both solve the same problem, but they have different tradeoffs.

FeatureEnumConst Object (as const)
Syntaxenum Foo { Bar }const Foo = { Bar: "bar" } as const
Runtime valueYesYes
IterableAwkwardObject.values(Foo) works
Reverse mappingNumeric onlyNo
Dropped when unusedDepends on the compilerYes, if nothing imports it
ExtendableNoYes (spread)

A good rule of thumb: use const objects with as const by default because they integrate naturally with the rest of JavaScript. Reach for enums when you need the auto-incrementing behavior of numeric enums or want the explicit enum keyword to signal intent clearly.

// Prefer const objects for role-based access — easy to iterate
const Role = {
  Admin:  "ADMIN",
  Editor: "EDITOR",
  Viewer: "VIEWER",
} as const;

type RoleType = typeof Role[keyof typeof Role];

function checkPermission(role: RoleType, action: string): boolean {
  if (role === Role.Admin) return true;
  if (role === Role.Editor && action !== "delete") return true;
  return false;
}

// Easily list all roles
const allRoles = Object.values(Role);
console.log("Roles:", allRoles);

console.log(checkPermission(Role.Admin, "delete"));   // true
console.log(checkPermission(Role.Editor, "delete"));  // false
console.log(checkPermission(Role.Editor, "edit"));    // true
console.log(checkPermission(Role.Viewer, "view"));    // false

A Worked Example: Traffic Light

Here is an enum for light states working alongside a const object for timing configuration. The simulation cycles through six light changes and prints a description of each state. Notice how Record<TrafficLight, number> forces the config to have exactly one entry per enum member — miss one and the compiler complains.

enum TrafficLight {
  Red    = "RED",
  Yellow = "YELLOW",
  Green  = "GREEN",
}

const LIGHT_DURATION: Record<TrafficLight, number> = {
  [TrafficLight.Red]:    30,
  [TrafficLight.Yellow]: 5,
  [TrafficLight.Green]:  25,
};

function getNextLight(current: TrafficLight): TrafficLight {
  switch (current) {
    case TrafficLight.Red:    return TrafficLight.Green;
    case TrafficLight.Green:  return TrafficLight.Yellow;
    case TrafficLight.Yellow: return TrafficLight.Red;
  }
}

function describeLight(light: TrafficLight): string {
  const duration = LIGHT_DURATION[light];
  switch (light) {
    case TrafficLight.Red:
      return `STOP    — Red light    (${duration}s)`;
    case TrafficLight.Yellow:
      return `CAUTION — Yellow light (${duration}s)`;
    case TrafficLight.Green:
      return `GO      — Green light  (${duration}s)`;
  }
}

// Simulate six light changes starting from Red
let current = TrafficLight.Red;
for (let i = 0; i < 6; i++) {
  console.log(describeLight(current));
  current = getNextLight(current);
}

Try extending this: add a PedestrianLight enum with Walk and Stop states, and make it toggle whenever the traffic light changes to Red.

Debug

This renders one label per item by looking each status up in a label table keyed by the const object's own values. It compiles, it runs, and every label comes back wrong. Commit a hypothesis about what key the lookup is actually using, then fix it so the line reads a=Not started, b=In progress, c=Finished.

import assert from "node:assert";

const Status = {
Todo: "TODO",
Active: "ACTIVE",
Done: "DONE",
} as const;

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

const LABEL: Record<Status, string> = {
TODO: "Not started",
ACTIVE: "In progress",
DONE: "Finished",
};

const items: { id: string; status: Status }[] = [
{ id: "a", status: Status.Todo },
{ id: "b", status: Status.Active },
{ id: "c", status: Status.Done },
];

function report(list: { id: string; status: Status }[]): string {
// Look each status up in the label table.
return list.map((item) => `${item.id}=${LABEL[item.status.toLowerCase() as Status]}`).join(", ");
}

const line = report(items);
assert.strictEqual(line, "a=Not started, b=In progress, c=Finished", "bad report line: " + line);
console.log(line);

Expected output:

a=Not started, b=In progress, c=Finished
Continue learning

Arrange the code

These five lines declare a status vocabulary as a const object, read its values out as an array, pick the second one, decide its label by comparing against a named member, and report both. The pieces are shuffled. Put them back in an order that runs, then answer what the puzzle is really testing: which line could not have moved, and what does the runner do if it does?

  1. const label = current === Status.Active ? "In progress" : "other";
  2. const Status = { Todo: "TODO", Active: "ACTIVE", Done: "DONE" } as const;
  3. const names = Object.values(Status);
  4. console.log(`${current} means ${label}`);
  5. const current = names[1];
Continue learning

Try It Yourself

Reading about enums and const assertions is not the same as modelling with them. This is a build task: a small program that reports its own pass/fail. You are given a Status const object (with its derived literal-union type) and a Priority numeric enum — the exact status/priority shapes the tracker capstone tags its items with. Three functions are stubbed. Run it as-is and it fails immediately, naming the first stub. Implement each until every check passes and it prints All checks passed.

The three functions reuse exactly what this lesson taught: iterating a const object's values with Object.values, comparing against a named const rather than a magic string, and reading a numeric enum member as the number it is. The starter has the data, the stubs, and the checks — you write only the logic inside each function.

Build

Finish the build. Three functions are stubbed and the checks below them fail until each returns the right value. Run it as-is to see which check fails first, decide what that function is missing, then implement the three functions until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.

import assert from "node:assert";

// The fixed value sets are given. Do NOT change these.
// A const object + as const: the source of a literal-union type.
const Status = {
Todo: "TODO",
Active: "ACTIVE",
Done: "DONE",
} as const;

// The union of Status's values: "TODO" | "ACTIVE" | "DONE".
type Status = (typeof Status)[keyof typeof Status];

// A numeric enum for priority, auto-incrementing from 1.
enum Priority {
Low = 1,
Medium,
High,
}

interface Item {
id: string;
status: Status;
priority: Priority;
}

const items: Item[] = [
{ id: "b", status: Status.Todo, priority: Priority.Low },
{ id: "c", status: Status.Done, priority: Priority.Medium },
{ id: "a", status: Status.Active, priority: Priority.High },
];

// TODO 1: return every supported status value as an array, in declaration order.
//   Use the const object, not a hand-typed list, so it stays in sync.
//   Example: Object.values(SomeConstObject) -> its values as an array
//   allStatuses() -> ["TODO", "ACTIVE", "DONE"]
function allStatuses(): Status[] {
// your code here
return []; // replace this
}

// TODO 2: count how many items are NOT done. Compare against the const value,
//   never the raw string "DONE".
//   countOpen(items) -> 2
function countOpen(list: Item[]): number {
// your code here
return 0; // replace this
}

// TODO 3: return the numeric priority value for the highest-priority item.
//   Priority is a numeric enum, so a bigger number is a higher priority.
//   topPriority(items) -> 3   (Priority.High)
function topPriority(list: Item[]): number {
// your code here
return 0; // replace this
}

// --- Build checks: these must all pass. Do not edit below this line. ---
assert.deepStrictEqual(allStatuses(), ["TODO", "ACTIVE", "DONE"], "TODO 1: allStatuses should list every const status value in order");
assert.strictEqual(countOpen(items), 2, "TODO 2: countOpen should count items whose status is not DONE");
assert.strictEqual(topPriority(items), 3, "TODO 3: topPriority should return the highest numeric priority (High = 3)");
assert.strictEqual(
topPriority([
	{ id: "x", status: Status.Todo, priority: Priority.Low },
	{ id: "y", status: Status.Active, priority: Priority.High },
	{ id: "z", status: Status.Done, priority: Priority.Medium },
]),
3,
"TODO 3: topPriority must find the highest priority wherever it sits, not just the first item",
);

console.log("All checks passed.");
console.log("Statuses:", allStatuses().join(", "));
console.log("Open items:", countOpen(items));
console.log("Top priority:", topPriority(items));

Expected output:

All checks passed.
Statuses: TODO, ACTIVE, DONE
Open items: 2
Top priority: 3
Continue learning

Once it passes, try two variations and predict each before running:

  1. Flip the comparison. In countOpen, change item.status !== Status.Done to item.status === Status.Done so it counts done items instead of open ones. Predict which check fails first before running. countOpen(items) now returns 1 (only the one done item), so TODO 2's check fires with AssertionError: TODO 2: countOpen should count items whose status is not DONE and 1 !== 2. An instructive assert failure showing that !== vs === against the same named const flips the whole meaning.
  2. Echo the priority's name. After the checks pass, add console.log("Top priority name:", Priority[topPriority(items)]); below the existing logs. Predict the new line before running. Because Priority is a numeric enum, it has a reverse mapping, so Priority[3] looks the number back up to its name: you get Top priority name: High. This changes the echoed output, not any check — and shows the reverse mapping the string-enum path deliberately gives up.

Capstone milestone

Milestone — the domain model. The tracker tags each item with a fixed status and a fixed priority: a status from a literal-union derived off an as const object, and a priority from a numeric enum. The Status/Priority vocabularies you just built are exactly those tags. Confirm you can model a closed set of named values as a const-assertion union and as an enum, and read each back the way real code does.

Hint: This is the domain-model milestone shared with *Data Structures* (typed collections) and *Interfaces and Types* (entity interfaces). Here the status and priority tags give each tracker item its closed set of named values — the vocabulary every later layer stores, validates, and reports on.

  • Derived a literal-union type from an as const object (typeof Obj[keyof typeof Obj])
  • Defined a numeric enum whose members are the numbers you compare and read
  • Compared against a named const value, never a raw magic string
  • Read a const object's values in order with Object.values instead of hand-typing them
Continue learning

Transfer

The as const object in this lesson gives you a closed set of statuses plus a union type derived from it. Rust reaches the same goal a different way: enum Status { Todo, Active, Done } declares the variants directly, and a match over them must cover every one. Both close the set, and both make a stray value a compile error. What is the real difference between the two, once the code is actually running?

// TypeScript
const Status = { Todo: "TODO", Active: "ACTIVE", Done: "DONE" } as const;
type Status = (typeof Status)[keyof typeof Status];
const current: Status = Status.Active;   // the value IS the string "ACTIVE"

// Rust
// enum Status { Todo, Active, Done }
// let current = Status::Active;         // the value is a variant, not a string
Continue learning

Key Takeaways

  • Numeric enums auto-increment from zero and support reverse mapping (Enum[value] gives the name), but produce more runtime code.
  • String enums use explicit string values and are readable at runtime, which is the usual reason to prefer them over numeric ones — though no survey measures how often each is chosen in practice.
  • as const freezes an object so TypeScript infers literal types rather than general ones, enabling the same type-safety as enums with plain JavaScript objects.
  • Use typeof Obj[keyof typeof Obj] to extract a union type of all values from a const object.
  • Const objects are easier to iterate with Object.values(), and an unused one is dropped by bundlers because it is a plain object — as const is a type-only annotation that changes nothing in the emitted JavaScript. An unused enum is dropped only when your compiler marks it as safe to remove (esbuild and SWC do; tsc does not) — prefer const objects by default and reach for enums when their specific features (auto-increment, explicit enum syntax) add clarity.
  • Both enums and const assertions eliminate magic strings and numbers from your code, making refactoring safer and intent clearer.

Pro Tip: Avoid mixing numeric and string members in a single enum (heterogeneous enums). TypeScript allows it, but it creates confusing runtime behavior and offers no practical benefit. Stick to all-numeric or all-string enums, or switch to a const object instead.

Next Steps

Enums and const assertions give you fixed sets of values — but how does TypeScript figure out which specific member of a union you're working with at any given point? Next, you'll learn type narrowing: the mechanism TypeScript uses to refine broad types into specific ones through typeof, instanceof, discriminated unions, and custom type guards.

Ready to continue? Head to Type Narrowing!

Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.