TypeScript Enums and Literal Types
A practical guide to enums and literal types in TypeScript — string enums, number enums, const enums, literal unions as a lighter alternative, and discriminated unions.
What you'll learn
- ✓How string and number enums work and what they emit at runtime
- ✓When const enums are appropriate, and when they are not
- ✓Literal types and how a union of literals replaces most enums
- ✓How to choose between an enum and a literal union
- ✓The discriminated-union pattern that makes complex state safe
Prerequisites
- •You are comfortable with basic types — see Basic Types
- •You know how interfaces and type aliases work — see Interfaces and Type Aliases
When a value can only be one of a fixed set — "admin" | "editor" | "viewer", or loading | success | error — TypeScript gives you two ways to model it: enums and literal types. They look similar from the outside and feel different in practice. This post walks through both, explains why most modern codebases prefer literal unions, and ends with the discriminated-union pattern that ties everything together.
Enums in one minute
An enum declares a named set of constants. There are two flavours: string and number.
enum Role {
Admin = "admin",
Editor = "editor",
Viewer = "viewer",
}
const r: Role = Role.Admin;
Role.Admin is the string "admin" at runtime. You can pass r to anything that expects a Role, and the compiler will reject "manager" because that is not one of the declared members.
Number enums are similar but assign integers by default:
enum Direction {
North,
East,
South,
West,
}
console.log(Direction.North); // 0
console.log(Direction.East); // 1
You can give them explicit values:
enum HttpStatus {
Ok = 200,
Created = 201,
BadRequest = 400,
NotFound = 404,
}
Number enums have one quirk that surprises people: they are reverse-mapped. The emitted JavaScript stores both Ok = 200 and 200 = "Ok", so HttpStatus[200] returns "Ok". String enums do not get this behaviour.
What enums emit at runtime
Unlike most TypeScript features, enums leave a footprint in the emitted JavaScript. A string enum compiles to something like:
var Role;
(function (Role) {
Role["Admin"] = "admin";
Role["Editor"] = "editor";
Role["Viewer"] = "viewer";
})(Role || (Role = {}));
This object exists in your bundle. For a small enum that costs almost nothing; for a tree of dozens, it adds up. More importantly, it means enums are not just types — they exist at runtime, and you can import them and use their members in regular JavaScript code.
For most type-only needs, this is overkill. That is where literal types come in.
Literal types
A literal type is a type whose only inhabitant is one specific value.
type Yes = "yes";
type FortyTwo = 42;
const a: Yes = "yes"; // ok
const b: Yes = "no"; // Error: Type '"no"' is not assignable to type '"yes"'.
const c: FortyTwo = 42; // ok
Yes and FortyTwo are not very useful on their own. They become useful in unions:
type Role = "admin" | "editor" | "viewer";
const r: Role = "admin"; // ok
const s: Role = "manager"; // Error: Type '"manager"' is not assignable to type 'Role'.
Role is a type, not a value. There is no runtime artefact — the type declaration disappears at compile time. The compiler still gives you the same exhaustiveness, the same autocompletion, the same misuse errors as the enum version.
Number literal unions work the same way:
type DiceRoll = 1 | 2 | 3 | 4 | 5 | 6;
For most “value can only be one of these” situations, this is the lighter, cheaper, simpler tool.
Enum vs. literal union: a practical comparison
Here is the same domain modelled both ways.
// Enum version
enum Status {
Idle = "idle",
Loading = "loading",
Success = "success",
Error = "error",
}
function render(s: Status) {
if (s === Status.Loading) return "Loading…";
// ...
}
render(Status.Idle);
// Literal-union version
type Status = "idle" | "loading" | "success" | "error";
function render(s: Status) {
if (s === "loading") return "Loading…";
// ...
}
render("idle");
What you gain with the literal union:
- No runtime emit. Pure types disappear at compile time.
- You can write the values directly.
render("idle")is shorter thanrender(Status.Idle). - JSON works seamlessly. A field from a network response is already a string — no conversion to an enum member needed.
What you give up:
- No namespaced constants. You cannot type
Status.to autocomplete. (In practice, your editor still completes the strings inside the union.) - No reverse lookup. Less useful than it sounds.
For new code, prefer literal unions by default. Reach for enums when you have an existing codebase that uses them everywhere, or when you genuinely need a named runtime constant exported across modules.
Try it yourself. Take an enum from any project you have — even one with three values — and rewrite it as a literal union. Update one consumer at a time and watch the compiler tell you exactly where every reference lives. Notice that the emitted JavaScript shrinks.
const enum: smaller emit, real caveats
To address the bundle-size concern, TypeScript also offers const enum:
const enum Direction {
North,
East,
South,
West,
}
const d = Direction.East;
Compiles to:
const d = 1 /* East */;
No object is emitted. Each member is inlined at every use site. This is fast and small.
The caveats are real:
const enumdoes not work cleanly when isolated modules are emitted independently — most modern build systems (Babel,esbuild, Vite,--isolatedModulesintsc) cannot inline an enum from another file.- You cannot iterate a
const enum’s members at runtime. - They cause headaches in libraries — a consumer with
--isolatedModulescannot use your library’sconst enumat all.
In practice: only use const enum inside a single project, never in a published library, and only when measurement shows it matters. For most code, a literal union does the job with zero emit and no caveats.
Literal types beyond strings
Object literals can be typed as literal unions too:
type Click = { type: "click"; x: number; y: number };
type Key = { type: "key"; code: string };
type Event = Click | Key;
function handle(e: Event) {
if (e.type === "click") {
// e is narrowed to Click here
console.log(e.x, e.y);
} else {
console.log(e.code);
}
}
This is a discriminated union — every variant shares a property (the discriminant, here type) whose literal value tells the compiler which variant you are looking at. Inside the if branch, TypeScript narrows the type accordingly.
This pattern is the workhorse of well-typed state. You see it everywhere — Result types, Redux actions, network state, parser tokens, event systems.
A complete discriminated-union example
State for an asynchronous request:
type RequestState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };
function render<T>(s: RequestState<T>): string {
switch (s.status) {
case "idle":
return "Click to start.";
case "loading":
return "Loading…";
case "success":
return `Got ${JSON.stringify(s.data)}`;
case "error":
return `Failed: ${s.error.message}`;
}
}
The compiler verifies two things automatically:
- You cannot access
dataon theloadingbranch. Insidecase "loading", the type ofsis only{ status: "loading" }. Trying to reads.datais a compile error. - You cannot forget a case. If you remove the
errorbranch, the function’s return type stops beingstring— TypeScript notices a case slips through and complains.
Add return assertNever(s) after the switch to make exhaustiveness checking explicit:
function assertNever(x: never): never {
throw new Error(`Unhandled variant: ${JSON.stringify(x)}`);
}
If a new variant is added later, the call to assertNever(s) becomes a compile error at every switch in the codebase — the compiler points you at every site that needs updating. This is the safety that makes well-typed state worth the upfront design work.
Choosing between enum and literal union
A short flowchart:
- Does the value need to exist at runtime as a named import — usable from regular JavaScript? Use a string
enum. - Is it a type-only set of allowed values that flows through the system as plain strings or numbers? Use a literal union. This covers most cases.
- Are you working in a codebase that already uses enums everywhere? Stay consistent. Mixing styles is worse than the marginal benefit of either.
- Are you writing a library? Avoid
const enumentirely. String enums are fine. Literal unions are usually nicer for consumers.
Mixing literals with object shapes
Literal types compose with everything else in the type system. The most common mix is a literal as a field within an interface:
interface Button {
label: string;
variant: "primary" | "secondary" | "ghost";
size: "sm" | "md" | "lg";
}
const ok: Button = { label: "OK", variant: "primary", size: "md" };
const bad: Button = { label: "OK", variant: "tertiary", size: "md" };
// Error: Type '"tertiary"' is not assignable to type '"primary" | "secondary" | "ghost"'.
When you write a React component, this is exactly the pattern you reach for to type its variant and size props. No enum needed. The IDE autocompletes the string options as soon as you type the opening quote.
Try it yourself. Type a Toast component with fields kind: "info" | "success" | "warning" | "error" and message: string. Write a render(t: Toast) function with a switch and an assertNever fallthrough. Add a fifth kind and watch the compiler walk you to every switch that needs updating.
A small worked example
A tiny finite-state machine for a checkout flow, using literal unions and a discriminated union.
type Step = "cart" | "address" | "payment" | "review" | "done";
interface Cart {
step: "cart";
items: { id: string; qty: number }[];
}
interface Address {
step: "address";
items: Cart["items"];
address: { line1: string; city: string; zip: string };
}
interface Payment {
step: "payment";
items: Cart["items"];
address: Address["address"];
payment: { last4: string };
}
interface Review {
step: "review";
items: Cart["items"];
address: Address["address"];
payment: Payment["payment"];
}
interface Done {
step: "done";
orderId: string;
}
type Checkout = Cart | Address | Payment | Review | Done;
function next(c: Checkout): Checkout {
switch (c.step) {
case "cart":
return {
step: "address",
items: c.items,
address: { line1: "", city: "", zip: "" },
};
case "address":
return {
step: "payment",
items: c.items,
address: c.address,
payment: { last4: "" },
};
case "payment":
return { step: "review", items: c.items, address: c.address, payment: c.payment };
case "review":
return { step: "done", orderId: "order_" + Date.now() };
case "done":
return c;
}
}
The step field is a literal-union discriminant. Each variant carries only the fields valid at that point in the flow. The compiler enforces that you cannot read c.payment until you have reached the payment step, and the switch in next is exhaustively checked.
Recap
You now know:
- Enums name a set of constants and exist at runtime; string enums are common, number enums get reverse-mapping
- Enums emit code into your bundle — sometimes useful, often not
- Literal types like
"admin"are types whose only value is that exact value - A union of literals replaces most enums with zero runtime emit and shorter call sites
const enuminlines values for smaller output but has real interop caveats; avoid in libraries- A discriminated union lets the compiler narrow types based on a shared literal field
assertNeverturns exhaustiveness into a compile-time guarantee across the whole codebase
Next steps
You can now design precise types for values that have a fixed set of variants. The next post is about the toolkit TypeScript ships for transforming and reusing existing types — Partial, Pick, Omit, Record, ReturnType, Awaited. These utility types are how you avoid duplicating shape definitions across your API, your forms, and your UI.
→ Next: TypeScript Utility Types: Partial, Pick, Omit, and More
Questions or feedback? Email codeloomdevv@gmail.com.