skip to content

In TypeScript, `enum Status { Active = 'active' }` rejects `const s: Status = 'active'`, while `type Status = 'active' | 'archived'` accepts it. What rule explains the difference, and why does comparing members of two different enums raise an error?

level: middleimportance: should knowfreq 52%

answer

  1. structural everywhere, except here
  2. identity comes from the declaration
  3. same runtime value, different types
  4. the comparison error is a category error
  5. protection at the cost of ergonomics

basics

~20 s

An enum declaration creates its own distinct type whose members are identified by where they were declared, not by their value, so a matching string literal is not assignable to it. A literal union is structural: any value of the right shape belongs.

solid answer

~50 s

TypeScript is structural almost everywhere — a value belongs to a type if its shape matches — but `enum` is a deliberate exception that behaves nominally. Each declaration mints its own type, and membership comes from the declaration rather than from the value, so `const s: Status = 'active'` fails with "Type '\"active\"' is not assignable to type 'Status'" even though the enum member's value *is* `'active'`. The same rule explains cross-enum comparisons: with `enum Direction { Up, Down }` and `enum Level { Low, High }`, both `Direction.Up` and `Level.Low` are `0` at runtime, but comparing values of the two types reports "This comparison appears to be unintentional because the types 'Direction' and 'Level' have no overlap". Comparing an enum-typed value to a plain number literal is refused for the same reason. Whether that is help or friction depends on the case: nominality stops two unrelated code sets being mixed, but every boundary — JSON, tests, config — must import the enum to produce a value.

code

typescript · 18 lines
typescript
enum Status { Active = 'active', Archived = 'archived' }
enum Direction { Up, Down }
enum Level { Low, High }

declare const d: Direction;
declare const l: Level;

// const bad1: Status = 'active';
// Error: Type '"active"' is not assignable to type 'Status'

// d === l;
// Error: This comparison appears to be unintentional because the types
// 'Direction' and 'Level' have no overlap

type Mode = 'light' | 'dark';
const fine: Mode = 'light';   // structural: the literal is the type

export { Status, d, l, fine };

go deeper

for a junior

Know that you cannot pass a plain string where an enum member is expected — you have to import the enum and use its member — while a string-literal union accepts the literal directly.

for a middle

Be able to name the rule: TypeScript is structural, enums are the nominal exception, and both the assignability error and the cross-enum comparison error follow from that single fact.

for a senior

Demonstrate judgment about which behaviour a domain needs: nominality where mixing two sets would be a real bug, structural literals where values cross serialization boundaries and fixtures must stay readable.

for a principal

Own the convention. Decide when the codebase pays for nominality and with which mechanism — enums or erasable branded types — so teams are not each inventing a different answer for identifier-like values.

## Structural by default, nominal on purpose TypeScript's type system is structural. Two object types with the same members are interchangeable regardless of their names, and a value belongs to a type when its shape matches. That is why literal unions feel so natural: `type Status = 'active' | 'archived'` says nothing more than "one of these two strings", and any expression whose type is one of those literals is a `Status`. `enum` breaks that rule on purpose. An enum declaration introduces a type whose members are identified by *where they were declared*. Two enums with byte-identical members are two unrelated types, and a bare literal with the right value is not a member of either. ```ts enum Status { Active = 'active', Archived = 'archived' } const s: Status = 'active'; // Error: Type '"active"' is not assignable to type 'Status' const ok: Status = Status.Active; // the only way in ``` So the enum's member type carries an identity beyond its value. This is TypeScript's one built-in nominal-ish construct. ## Cross-enum comparison The same identity rule produces the comparison error people find surprising: ```ts enum Direction { Up, Down } enum Level { Low, High } declare const d: Direction; declare const l: Level; d === l; // Error: This comparison appears to be unintentional because the types // 'Direction' and 'Level' have no overlap d === 5; // Error: ... types 'Direction' and '5' have no overlap ``` At runtime both `Direction.Up` and `Level.Low` are the number `0`, so the comparison would be `true`. The checker refuses it anyway, because the two types share no members *as types*. Reading that error as "the values can never be equal" is the usual misreading; it means "nothing is a member of both types, so you almost certainly did not mean this". This is genuinely useful. If `Direction` and `Level` were both plain numeric aliases, mixing them would compile silently. The enum catches the category error. ## The other side of the trade Nominality costs you at every place a value is produced from outside the type system. ```ts declare const raw: string; // e.g. from JSON const s: Status = raw; // Error: Type 'string' is not assignable to type 'Status' ``` Here the error is welcome — it forces validation. But you also cannot write `{ status: 'active' }` in a test fixture, a config file, or a mock; you must import `Status` and write `Status.Active`. Every module that merely *mentions* a status now takes a runtime dependency on the enum, which is exactly the coupling the emit-cost argument complains about. With a literal union the same code just works: ```ts type Status2 = 'active' | 'archived'; const a: Status2 = 'active'; // fine const obj = { status: 'active' } as const satisfies { status: Status2 }; ``` And the check that mattered is still there — a value typed `string` is *not* assignable to `Status2`, so external data still has to be narrowed. What you give up is only the protection against mixing two different literal unions that happen to share a member: `type Colour = 'red' | 'green'` and `type Light = 'red' | 'amber'` overlap at `'red'`, and assigning between them where they overlap is allowed. ## Choosing between the two behaviours Prefer the union when the values are data that crosses boundaries — payload fields, query parameters, discriminants in messages you serialise. Ergonomics dominate there, and the values genuinely *are* those strings. Prefer nominality when mixing two sets would be a real bug and the sets never travel as raw literals — internal identifiers, unit-like quantities, keys that must not be confused. Note that an enum is not the only way to get it: TypeScript's branded-type pattern gives you nominal behaviour on top of a structural type without emitting anything, and it is the tool to reach for when you want nominality *and* erasability. Reach for the enum when its declaration-site behaviour is the point and you accept the runtime object that comes with it. ## Answering well Name the rule first — structural everywhere, nominal for enums — then show both symptoms (literal not assignable, cross-enum comparison rejected) as consequences of that one rule. Close on the trade rather than a verdict: nominality is a feature when it prevents mixing and a tax at every boundary where values come from outside your code.

  • Does the same restriction apply to numeric enums, given both members are just numbers at runtime?
    Yes for the declaration rule — a member of one numeric enum is not assignable to another enum type, and comparing values of two enum types is rejected. The asymmetry is that a value typed `number` *is* assignable to a numeric enum type, so numeric enums are nominal against other enums but leaky against plain numbers.
  • How would you get nominal behaviour without the enum's runtime object?
    Use TypeScript's branded-type pattern: intersect the underlying type with a marker that only your factory function can produce. It is erased entirely, so it costs nothing at runtime, and it composes with literal unions. The cost is that values must go through the factory, which is the same discipline the enum imposes.
  • Two literal unions share a member. Is assigning between them allowed?
    Where the value is in both, yes — literal unions are structural, so a value typed `'red'` satisfies any union containing `'red'`. That is precisely the protection an enum would give you and a union will not, and it is the case where reaching for nominality is justified.

saying these in an interview costs you the question

  • Says two enums with identical members are the same type
  • Reads the no-overlap comparison error as the values never being equal
  • Thinks casting a matching string literal to the enum is harmless
  • Believes TypeScript is nominal in general
  • Claims a literal union accepts any string value

context