Suppose an order can be pending, paid or refunded. In TypeScript you can write that as enum OrderStatus { Pending = 'pending', Paid = 'paid', Refunded = 'refunded' }, as type OrderStatus = 'pending' | 'paid' | 'refunded', or as a frozen object with a derived type. All three give you autocomplete and catch typos. They differ in what exists at runtime, in what values they accept, and in which build tools can handle them. Those differences are what an interviewer is probing when they ask which one you’d use.
Enums are code, not just types
Most TypeScript syntax disappears when you compile. Enums don’t. Take two small ones:
enum Priority { Low, Medium, High }
enum Role { Admin = 'admin', Viewer = 'viewer' }
console.log(Object.values(Priority));
console.log(Object.values(Role));
console.log(Priority[2], Priority.High);TypeScript 7.0 emits this JavaScript for the declarations:
var Priority;
(function (Priority) {
Priority[Priority["Low"] = 0] = "Low";
Priority[Priority["Medium"] = 1] = "Medium";
Priority[Priority["High"] = 2] = "High";
})(Priority || (Priority = {}));
var Role;
(function (Role) {
Role["Admin"] = "admin";
Role["Viewer"] = "viewer";
})(Role || (Role = {}));Running the compiled file prints:
[ 'Low', 'Medium', 'High', 0, 1, 2 ]
[ 'admin', 'viewer' ]
High 2The numeric enum builds a reverse mapping: Priority[2] is 'High', and the object holds six keys, not three. That’s the source of a classic bug. Someone renders a dropdown with Object.values(Priority).map(...) and gets six options, three of them numbers. String enums don’t get reverse mappings, so Object.values(Role) is what you’d expect.
Being code also means the enum is a real object your bundle ships and a real binding your modules import. That’s rarely a performance problem, but it does matter for tooling, covered below.
What each option accepts
The bigger difference is assignability. Here’s a file with four assignments TypeScript rejects:
enum Priority { Low, Medium, High }
enum Role { Admin = 'admin', Viewer = 'viewer' }
const p: Priority = 42;
const p2: Priority = 1;
const r: Role = 'admin';
function setRole(r: Role) {}
setRole(Role.Admin);
const fromJson = JSON.parse('{"role":"admin"}') as { role: string };
setRole(fromJson.role);
type Status = 'pending' | 'paid' | 'refunded';
const s: Status = 'paid';
const bad: Status = 'piad';status.ts(4,7): error TS2322: Type '42' is not assignable to type 'Priority'.
status.ts(6,7): error TS2322: Type '"admin"' is not assignable to type 'Role'.
status.ts(11,9): error TS2345: Argument of type 'string' is not assignable to parameter of type 'Role'.
status.ts(15,7): error TS2322: Type '"piad"' is not assignable to type 'Status'.Go through them one at a time.
Numeric enums are looser than they look. 42 is rejected because TypeScript knows the literal isn’t one of the members, but 1 is accepted. And a value typed as plain number, such as a column read from a database, is accepted with no error at all:
declare const fromDb: number;
const p3: Priority = fromDb; // compilesSo a numeric enum doesn’t protect you at the boundary where bad data actually arrives. It only catches literal typos in your own source.
String enums are nominal. 'admin' is exactly the runtime value of Role.Admin, yet TypeScript refuses it. Each member of a string enum is its own type, and only the enum member itself fits. Inside one codebase that’s a feature: you can’t pass a random string where a role is expected. At the edges it’s friction. Every value from JSON, a URL parameter or a form arrives as string, so you need a cast or a guard before you can call setRole. A cast (as Role) checks nothing, which defeats the point.
A literal union is structural. 'paid' fits because it is one of the allowed strings, and 'piad' fails with a clear message. Tests can pass string literals directly, and API types generated from an OpenAPI schema line up without conversion.
The as const object pattern
Teams often reach for enums because they want two things a bare union lacks: named constants (OrderStatus.Paid) and a list of values at runtime for validation or dropdowns. You can get both from an ordinary object:
export const OrderStatus = {
Pending: 'pending',
Paid: 'paid',
Refunded: 'refunded',
} as const;
export type OrderStatus = (typeof OrderStatus)[keyof typeof OrderStatus];
const ALL: readonly OrderStatus[] = Object.values(OrderStatus);
export function isOrderStatus(v: unknown): v is OrderStatus {
return typeof v === 'string' && (ALL as readonly string[]).includes(v);
}
export function label(s: OrderStatus): string {
switch (s) {
case OrderStatus.Pending: return 'Awaiting payment';
case 'paid': return 'Paid';
case OrderStatus.Refunded: return 'Refunded';
default: {
const unreachable: never = s;
return unreachable;
}
}
}
const body: unknown = JSON.parse('{"status":"refunded"}').status;
if (isOrderStatus(body)) console.log(body, label(body));
console.log(ALL);This compiles under --strict and runs directly in Node 22 (more on why in the next section), printing:
refunded Refunded
[ 'pending', 'paid', 'refunded' ]A few things are going on. as const makes every property readonly and keeps each value as its literal type instead of widening it to string. The type alias with the same name as the value is legal because types and values live in separate namespaces, so OrderStatus means the object in expressions and the union in annotations. The switch mixes OrderStatus.Pending and 'paid' freely, since both are the same literal type.
The never check gives exhaustiveness. Delete the Refunded case and compile again, and TypeScript reports:
order-status.ts(19,13): error TS2322: Type '"refunded"' is not assignable to type 'never'.Enums support the same exhaustiveness trick, so this isn’t a reason to choose one over the other. The real advantages of the object are that plain strings are accepted after a guard, and nothing outside standard JavaScript is involved.
Tooling: type stripping, isolatedModules and const enum
More and more TypeScript runs through tools that remove types without type-checking: esbuild, SWC, Babel and Node’s built-in type stripping. Those tools work on one file at a time and can only handle syntax that disappears cleanly. An enum doesn’t, because it has to become an object. Running the first example straight through Node 22.23 fails:
SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only modeTypeScript has a flag that warns you ahead of time. With erasableSyntaxOnly enabled, the same file gives:
enums.ts(1,6): error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.const enum was designed to avoid the runtime object: the compiler inlines Dir.Up as "UP" at each use. That requires seeing the enum’s declaration while compiling the file that uses it, which single-file tools can’t do. TypeScript’s isolatedModules flag reports the cases that can’t work, for example importing a const enum from a declaration file:
app.ts(1,45): error TS2748: Cannot access ambient const enums when 'isolatedModules' is enabled.Library authors in particular should avoid exporting const enum, because their consumers may compile with exactly these tools.
So which one?
For application code, a union of string literals is the default, with an as const object when you want named constants or a runtime list. That combination works with every build tool, accepts data from the outside world after validation, and has no surprises in Object.values.
Enums are still reasonable in a codebase that already uses them consistently and compiles with tsc. String enums give you nominal safety that some teams want: a function taking Role can’t be called with an arbitrary string, even a correct one. Numeric enums make sense for bit flags (Read = 1 << 0, Write = 1 << 1), where the numbers carry meaning. What interviewers want to hear isn’t a rule but the trade-off: enums buy names and nominal checks at the cost of runtime code, looser numeric checks and friction with type stripping.
Interview question to try
A service receives webhook payloads with "event": "invoice.paid" | "invoice.voided" | "invoice.overdue". A colleague models it as enum InvoiceEvent { Paid = 'invoice.paid', ... } and handlers do switch (payload.event as InvoiceEvent). A new event, invoice.disputed, starts arriving and nothing fails at compile time. What went wrong, and how would you restructure it?
Answer, with reasoning
The as InvoiceEvent cast told the compiler the value was already a known member, so no check ran. At runtime the new string matched no case, fell through the switch, and was silently ignored. The enum itself wasn’t the bug, but its nominal typing pushed the author into a cast, because a plain string from JSON doesn’t fit a string enum.
I’d define const InvoiceEvent = { Paid: 'invoice.paid', Voided: 'invoice.voided', Overdue: 'invoice.overdue' } as const and derive the union type from it. At the entry point, an isInvoiceEvent guard built from Object.values(InvoiceEvent) checks the string. If the guard fails, the handler logs the unknown event name and returns a 2xx so the provider doesn’t retry forever, or rejects it, depending on the contract. Inside the guard, the switch over the narrowed union gets a never default, so when someone adds Disputed to the object, every handler that hasn’t dealt with it stops compiling. The result: unknown values are caught at runtime at the boundary, and missing cases are caught at compile time everywhere else.