skip to content

Given `enum Priority { Low = 1, High = 2 }` in TypeScript, does `const p: Priority = value` compile when `value` is typed `number`, and what does the answer mean for enum-typed data that arrived as JSON?

level: seniorimportance: must knowfreq 48%

answer

  1. an annotation is a claim, not a check
  2. the literal is caught, the wide type is not
  3. external data arrives as plain number
  4. reverse lookup can be typed string yet undefined
  5. string-literal unions do not share the hole

basics

~20 s

Yes, it compiles. TypeScript still lets any value of type number flow into a numeric enum type, so a number parsed from JSON is accepted unchecked. Only an out-of-range numeric literal is rejected, so validate at the boundary.

solid answer

~50 s

Assigning a `number`-typed value to a numeric enum type is accepted — I checked this on TypeScript 6.0. What the compiler does catch is an out-of-range *literal*: `const p: Priority = 3` fails with "Type '3' is not assignable to type 'Priority'", because the enum's type is the union of its members. But `declare const n: number; const p: Priority = n;` compiles happily, and so does `schedule(payload.priority)` when the payload field is typed `number`. That is a deliberate hole: the annotation is a claim, not a check, and nothing verifies it at runtime. The practical damage shows up downstream — a `switch` over the members silently hits no case, and a reverse lookup like `Priority[p]` is typed `string` while actually evaluating to `undefined`. The fix is not a better annotation but a real runtime check at the boundary. Notably a union of string literals does not have this hole: a plain `string` is rejected outright.

code

typescript · 19 lines
typescript
enum Priority { Low = 1, High = 2 }

declare const payload: { priority: number };

function schedule(p: Priority): string {
  switch (p) {
    case Priority.Low: return 'later';
    case Priority.High: return 'now';
  }
  return 'unreachable?';
}

// Compiles with no error even when payload.priority is 7:
const plan = schedule(payload.priority);

// Typed string, but Priority[7] is undefined at runtime:
const label: string = Priority[payload.priority as Priority];

export { plan, label };

go deeper

for a junior

Know that a type annotation does not check anything at runtime, and that a number coming from JSON can end up labelled as an enum member even when it is not one.

for a middle

Be able to demonstrate the difference between an out-of-range literal, which the checker rejects, and a value typed number, which it accepts, and explain why the union of member types explains the first case.

for a senior

Show where this bites in a live system: unvalidated payloads, a switch that silently matches nothing, a reverse lookup typed string that is undefined. Then propose boundary validation rather than a stricter annotation.

for a principal

Frame it as a trust-boundary policy: decide once where external data is parsed and validated across services, so no individual developer is relied on to remember that an enum annotation proves nothing.

## What actually compiles Start from the observed behaviour, checked against TypeScript 6.0: ```ts enum Priority { Low = 1, High = 2 } const a: Priority = 3; // Error: Type '3' is not assignable to type 'Priority' const b: Priority = 1; // fine — matches Priority.Low declare const n: number; const c: Priority = n; // NO ERROR — any number-typed value is accepted function schedule(p: Priority) { /* ... */ } schedule(3); // Error: Argument of type '3' is not assignable schedule(n); // NO ERROR ``` So the checker knows the enum's type is the union of its member types — that is why the literal `3` is rejected. But it keeps a special assignability rule that lets the wide type `number` flow into a numeric enum type. Once the value is a `number` rather than a literal, the check evaporates. ## Why the hole exists Numeric enums were designed to model the JavaScript and C-style idiom of integer constants, including bit-flag usage where you legitimately compute values (`Flags.A | Flags.B`) that are not themselves declared members. Rejecting all `number` values would break that idiom, so the language accepts the wide type. The result is the most-cited unsoundness of TypeScript's basic types: a `Priority`-annotated variable does not guarantee its value is `1` or `2`. An annotation is a *claim about* a value, never a check *of* a value — types are erased, so there is no runtime gate anywhere in the emitted code. Every TypeScript unsoundness has this same root, and numeric enums are the version of it you meet earliest. ## Where it bites in production External data is the danger zone, because that is where `number` comes from: ```ts type Payload = { priority: number }; declare const payload: Payload; schedule(payload.priority); // compiles; payload.priority might be 7 ``` A `payload.priority` of `7` now travels through the system carrying the type `Priority`. Two failures follow: **Silent fall-through.** A `switch` written over the declared members handles `Low` and `High`, hits neither, and falls off the end. If the function's return type is not annotated, the checker may even accept the resulting `undefined` path without complaint. **A lie in the reverse lookup.** For `enum Priority { Low = 1, High = 2 }`, the emitted object maps both directions, so `Priority[p]` is typed `string`. At runtime `Priority[7]` is `undefined`, and the very next `.toUpperCase()` throws — a `TypeError` on an expression the compiler assured you was a string. The same class of bug arrives through a cast. `const p = payload.priority as Priority` compiles by definition: an assertion silences the checker without inspecting anything. ## The actual fix Validate where untyped data enters, once, and never again: ```ts enum Priority { Low = 1, High = 2 } function toPriority(value: unknown): Priority { if (value === Priority.Low || value === Priority.High) return value; throw new Error(`Unknown priority: ${String(value)}`); } ``` The check is ordinary runtime code, and it is the only thing that makes the annotation true. A schema-validation library does the same job at scale. The important habit is that the boundary function returns the narrow type and everything inside the system can then trust it. ## How the alternatives compare A union of string literals does not share the hole: ```ts type Status = 'active' | 'archived'; declare const raw: string; const s: Status = raw; // Error: Type 'string' is not assignable to type 'Status' ``` There is no rule letting the wide `string` into a literal union, so the compiler forces you to narrow — usually by writing exactly the runtime check you needed anyway. That asymmetry is one of the strongest technical arguments for preferring string-literal unions over numeric enums, quite apart from bundle size. String enums are also stricter than numeric ones here: a `string`-typed value is rejected. But they remain assertion-bypassable like everything else, so boundary validation is still your job. ## What a strong answer sounds like State the behaviour precisely — literal rejected, `number` accepted — attribute it to the wide-type assignability rule rather than to a bug, then move immediately to the consequence for external data and the boundary-validation fix. Finishing with the contrast against string-literal unions shows you understand this as a modelling choice, not trivia.

  • If a numeric enum accepts any number, why is `const p: Priority = 3` an error?
    Because the enum's type is the union of its member types, and the literal `3` matches none of them, so the checker rejects it outright. The special rule only applies to the wide `number` type. Widening the value first — assigning it to a `number` variable — makes the same value pass, which is a good demonstration of how narrow the guarantee really is.
  • Does writing `as Priority` on the parsed value make it safe?
    No. An assertion tells the checker to stop objecting; it performs no conversion and no check, and the emitted JavaScript contains nothing at all where the assertion was. It converts a compile-time error into a runtime surprise later, usually far from the parsing code.
  • Would switching to a string enum close the hole?
    It closes this specific one: a value typed `string` is not assignable to a string enum, so external data cannot silently flow in. It does not close assertions or unchecked casts, and the enum still emits runtime code. You still validate at the boundary; you just get a compile error reminding you to.
  • How would you catch this class of bug across an existing codebase?
    Find the boundaries first — HTTP handlers, message consumers, database rows, `JSON.parse` results — and check what type they hand off. Anywhere a `number` or `any` reaches an enum-typed parameter is a candidate. Introducing a validated parse step per boundary fixes whole families of call sites at once, which is cheaper than auditing every use of the enum.

saying these in an interview costs you the question

  • Says the type annotation guarantees the value is a declared member
  • Thinks enabling strict closes the numeric-enum hole
  • Uses as Priority on parsed JSON and calls it validated
  • Assumes a switch over enum members is exhaustive for external data
  • Believes reverse lookup always returns a real member name

context