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.
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);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 (07-generics). 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.
Recall
Without scrolling up: the as const trick above derives the type 'light' | 'dark' | 'system' from an object's values. In 06-interfaces-and-types you could already write that exact type by hand, without any object. How, and what is that kind of type called?
Enums vs Const Assertions: When to Use Each
Both solve the same problem, but they have different tradeoffs.
| Feature | Enum | Const Object (as const) |
|---|---|---|
| Syntax | enum Foo { Bar } | const Foo = { Bar: "bar" } as const |
| Runtime value | Yes | Yes |
| Iterable | Awkward | Object.values(Foo) works |
| Reverse mapping | Numeric only | No |
| Dropped when unused | Depends on the compiler | Yes, if nothing imports it |
| Extendable | No | Yes (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.
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
Once it passes, try two variations and predict each before running:
- Flip the comparison. In
countOpen, changeitem.status !== Status.Donetoitem.status === Status.Doneso it counts done items instead of open ones. Predict which check fails first before running.countOpen(items)now returns1(only the one done item), so TODO 2's check fires withAssertionError: TODO 2: countOpen should count items whose status is not DONEand1 !== 2. An instructive assert failure showing that!==vs===against the same named const flips the whole meaning. - 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. BecausePriorityis a numeric enum, it has a reverse mapping, soPriority[3]looks the number back up to its name: you getTop 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
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, are readable at runtime, and are the most common choice in TypeScript codebases.
as constfreezes 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 constis 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;tscdoes not) — prefer const objects by default and reach for enums when their specific features (auto-increment, explicitenumsyntax) 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.
Next lesson
Type Narrowing
Learn TypeScript type narrowing with typeof, instanceof, in operator, discriminated unions, and user-defined type guards.
25 min