TypeScript Numeric Enums

A numeric enum is TypeScript’s way of giving a set of related numeric constants readable names — instead of scattering magic numbers like 0, 1, and 2 through your code, you write Direction.Up, Direction.Down, and so on. Numeric enums are the original, default flavor of enum in TypeScript, and they still show up constantly in real codebases: HTTP status groups, permission flags, state-machine states, and more. This lesson covers exactly how they’re declared, how the compiler assigns and reverse-maps their values, where they quietly let unsafe values through, and how to use them well.

Overview: How Numeric Enums Work

An enum declaration creates both a type and a value. At the type level, Direction becomes a type whose members (Direction.Up, Direction.Down, …) are treated as distinct literal subtypes of number, which lets the compiler narrow and check them the way it narrows string or number literals in a switch. At the value level, the compiler emits a real JavaScript object at runtime — and this is unusual. Interfaces, type aliases, and generic type parameters disappear entirely when TypeScript compiles to JavaScript (they are fully erased). A plain numeric enum is the exception: it survives compilation as an actual object literal that you can inspect, loop over, and pass around like any other runtime value.

By default, members start at 0 and increase by 1 for each subsequent member, in declaration order. You can override any member with an explicit numeric initializer, and any member declared after an explicit numeric literal continues auto-incrementing from that new value. This differs from string enums, which have no auto-increment behavior at all and must be given (or all default to) explicit string values.

Numeric enums also come with a distinctive extra: a reverse mapping. In addition to Direction.Up === 0, the compiled object also stores Direction[0] === "Up", so you can go from the numeric value back to the member’s name at runtime. String enums do not get this reverse mapping, since it would be redundant — the value already looks like a name.

Syntax

enum EnumName {
  MemberA,
  MemberB = 5,
  MemberC,
}

console.log(EnumName.MemberA, EnumName.MemberB, EnumName.MemberC);

Output:

0 5 6
  • enum EnumName — declares both a type named EnumName and a runtime object of the same name.
  • MemberA — with no initializer, the first member defaults to 0.
  • MemberB = 5 — an explicit numeric initializer; any constant numeric expression is allowed.
  • MemberC — with no initializer, it becomes the previous member’s value plus one, i.e. 6, not 2. Auto-increment always continues from the last numeric value seen, not from the member’s position.

Examples

Example 1: A Basic Numeric Enum in a Switch

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";
    default:
      throw new Error("Unknown direction");
  }
}

console.log(move(Direction.Up));
console.log(Direction.Up, Direction.Down, Direction.Left, Direction.Right);
console.log(Direction[2]);

Output:

Moving up
0 1 2 3
Left

The four members auto-increment from 0 to 3. The switch narrows direction to each specific member inside its case, which is why TypeScript is happy returning a plain string from every branch. The last line shows the reverse mapping: indexing the enum object with the number 2 gives back the member name "Left" as a plain string, something a string enum cannot do.

Example 2: Custom Start Values and Continued Auto-Increment

enum HttpStatus {
  OK = 200,
  Created = 201,
  BadRequest = 400,
  Unauthorized,
  Forbidden,
  NotFound = 404,
}

function describeStatus(code: HttpStatus): string {
  return `${code} ${HttpStatus}`;
}

console.log(describeStatus(HttpStatus.OK));
console.log(describeStatus(HttpStatus.Unauthorized));
console.log(HttpStatus.Forbidden);
console.log(HttpStatus.NotFound);

Output:

200 OK
401 Unauthorized
402
404

Grouping related constants like HTTP status codes is a classic numeric-enum use case. Unauthorized has no initializer, so it continues from BadRequest = 400, becoming 401; Forbidden follows as 402. NotFound resets the sequence with its own explicit value, 404. The describeStatus function uses the reverse mapping (HttpStatus) to print the human-readable member name next to its number.

Example 3: Numeric Enums as Bit Flags

enum Permission {
  None = 0,
  Read = 1 << 0,
  Write = 1 << 1,
  Execute = 1 << 2,
}

function hasPermission(userPermissions: Permission, check: Permission): boolean {
  return (userPermissions & check) === check;
}

const editorAccess: Permission = Permission.Read | Permission.Write;

console.log(hasPermission(editorAccess, Permission.Read));
console.log(hasPermission(editorAccess, Permission.Execute));
console.log(editorAccess);

Output:

true
false
3

Because numeric enum members are just numbers under the hood, they combine naturally with bitwise operators. Using powers of two (1 << 0, 1 << 1, 1 << 2, i.e. 1, 2, 4) means each flag occupies its own bit, so they can be combined with | and tested with & without colliding. editorAccess combines Read (1) and Write (2) into 3; checking for Execute (4) correctly returns false since bit 2 was never set.

Under the Hood: Compilation and Type Erasure

Most TypeScript syntax disappears completely at compile time — type annotations, interfaces, and generics are checked once and then erased, leaving plain JavaScript with no trace of them. Numeric (and string) enums are the notable exception: the compiler generates a real object to hold the mapping. Conceptually, enum Direction { Up, Down } compiles to something equivalent to an immediately-invoked function that builds an object with Direction.Up = 0, Direction[0] = "Up", Direction.Down = 1, and Direction[1] = "Down". That object is what actually exists when your program runs; the type Direction that the compiler used to check your code is gone by then — only the values remain.

This has two practical consequences. First, referencing Direction as a value (not just a type) works fine at runtime — you can pass it to a function, log it, or iterate its keys, because it is a genuine object. Second, it means numeric enums have a small runtime footprint and an extra generated object per enum, unlike type aliases or interfaces which cost nothing at runtime. If you want the compile-time convenience of an enum with zero runtime object at all, TypeScript offers const enum, which inlines every reference to its literal value during compilation instead of emitting an object — at the cost of losing the reverse mapping and some tooling flexibility (and it does not work with isolated-file transpilers, since inlining requires whole-program knowledge of the enum's values).

At the type-checking level, the compiler treats each numeric enum member as its own literal type, all of which are subtypes of the enum type as a whole. This is what lets a switch over an enum-typed value narrow correctly case by case, exactly as it would for a union of string or number literals.

Common Mistakes

Mistake 1: Assuming the Enum Type Rejects Invalid Numbers

A well-known weak spot of numeric enums is that TypeScript allows any number to be assigned where a numeric enum type is expected, with no error at all — unlike string enums, which only accept their exact members.

enum ShapeKind {
  Circle,
  Square,
}

function draw(kind: ShapeKind): void {
  console.log(`Drawing shape #${kind}`);
}

draw(3);

Output:

Drawing shape #3

Notice that tsc reports no error here, even though 3 is not a valid ShapeKind member (only 0 and 1 exist). This is the actual mistake: developers often assume a numeric enum parameter is as restrictive as a union of string literals, but numeric enums are structurally compatible with the entire number domain. If you need a hard guarantee that only declared members reach a function, add an explicit runtime check:

enum ShapeKind {
  Circle,
  Square,
}

function isShapeKind(value: number): value is ShapeKind {
  return value === ShapeKind.Circle || value === ShapeKind.Square;
}

function draw(kind: ShapeKind): void {
  console.log(`Drawing shape #${kind}`);
}

const input = 3;
if (isShapeKind(input)) {
  draw(input);
} else {
  console.log(`${input} is not a valid ShapeKind`);
}

Output:

3 is not a valid ShapeKind

The type predicate value is ShapeKind gives you an actual runtime guard, closing the gap that the enum type alone leaves open. If this bothers you often, consider whether a string enum or a union of string literals (which are far stricter) would fit the data better than a numeric enum.

Mistake 2: Forgetting an Initializer After a Computed Member

function getStatusCode(): number {
  return 1;
}

enum Status {
  Active = getStatusCode(),
  Inactive,
}

This fails to compile with "Enum member must have initializer." Once a member's value comes from a non-constant expression (here, a function call), TypeScript can no longer auto-increment for the members that follow — it has no compile-time-known number to increment from. Every subsequent member must then get its own explicit initializer:

function getStatusCode(): number {
  return 1;
}

enum Status {
  Active = getStatusCode(),
  Inactive = 2,
}

console.log(Status.Active, Status.Inactive);

Output:

1 2

The fix is simply to give Inactive an explicit value once the sequence includes a computed member.

Best Practices

  • Use numeric enums for genuinely numeric, ordered, or bit-flag-like data (HTTP status families, priority levels, permission bits); prefer string enums or literal unions when the values are better read as distinct labels than as numbers.
  • Don't rely on the enum type alone to reject invalid values at the boundaries of your program (parsed JSON, network responses, user input) — validate with an explicit type guard, since any number is otherwise assignable.
  • When using bit flags, pick powers of two (1 << 0, 1 << 1, ...) so flags can be safely combined with | and tested with &.
  • Avoid mixing computed (non-constant) initializers with auto-incrementing members; if one member is computed, give every later member its own explicit value.
  • Reach for const enum only when you're certain every consumer of the enum is compiled together with it (not through isolated-module transpilation) and you want to avoid generating a runtime object.
  • Give the first member of a sequential enum a deliberate value (even if it's 0) rather than leaving it implicit, if the exact numbers matter to anyone reading or persisting them — explicit is easier to audit later.

Practice Exercises

  • Declare a numeric enum Weekday with members Monday through Sunday, letting them auto-increment from 0. Write a function isWeekend(day: Weekday): boolean that returns true only for Saturday and Sunday.
  • Declare a numeric enum LogLevel with Debug = 10, Info = 20, Warn = 30, and Error = 40. Write a function that takes a LogLevel and a message, and only prints the message if the level is Warn or higher. (Hint: numeric enum members can be compared directly with >=.)
  • Declare a bit-flag enum FilePermission with Read, Write, and Delete as separate bits (using 1 <<). Create a combined value representing read-and-delete access but not write, then write a check that confirms Write is absent from it.

Summary

  • Numeric enums auto-increment from 0 by default, and continue incrementing from the last explicit numeric literal seen.
  • Unlike almost everything else in TypeScript, numeric enums are not fully erased — they compile to a real runtime object with a bidirectional (value-to-name and name-to-value) mapping.
  • Any number is assignable to a numeric enum type without a compiler error — this is a known weak point, so validate untrusted values explicitly with a type guard.
  • Powers-of-two numeric enums work well as bit flags, combined with | and tested with &.
  • Once a member's initializer is a non-constant expression, every following member needs its own explicit initializer, or tsc reports "Enum member must have initializer."