Choosing: Enum vs. Union vs. Const Object
Intermediate · 18 min read · ▶ live playground · ✦ checkpoint
Most application choices should start as literal unions. Reach for a const object when you also need runtime values to loop over or hand to other code, and reserve string enums for places where an enum-shaped runtime API is already part of the contract.
The modern default: literal unions
When the checker only needs a closed set of values, a literal union is the smallest honest tool. There is no runtime object, no reverse mapping, and no synchronization problem between "the values" and "the type."
Use this for workflow states, button variants, permission names, event names, analytics labels, and ordinary domain choices. If your program only needs the type, the union is clearer than an enum and lighter than a runtime object.
The tradeoff is that a union is type-only. You cannot loop over PublishState because PublishState disappears before runtime. If the UI needs a dropdown list, a validator needs a list of allowed strings, or analytics code needs a map from value to label, move from a plain union to a const object or const array.
The const-object pattern
The object pattern gives you one runtime value and derives the type from it. Use as const for literal precision and satisfies when you want the object checked against an expected value set:
type StatusValue = "draft" | "published" | "archived";
type StatusMap = {
[name: string]: StatusValue;
};
const STATUS = {
Draft: "draft",
Published: "published",
Archived: "archived",
} as const satisfies StatusMap;
type Status = (typeof STATUS)[keyof typeof STATUS];
function describeStatus(status: Status): string {
if (status === STATUS.Draft) {
return "still being written";
}
if (status === STATUS.Published) {
return "visible to readers";
}
return "kept for history";
}
console.log(describeStatus(STATUS.Published));
console.log(Object.values(STATUS).join(", "));→ visible to readers
→ draft, published, archived
Read the type line as: "take the type of the STATUS object, then take the values at its keys." Section 10 will make keyof typeof feel routine; here you only need the pattern.
The satisfies StatusMap part catches values outside the approved set:
type StatusValue = "draft" | "published" | "archived";
type StatusMap = {
[name: string]: StatusValue;
};
const STATUS = {
Draft: "draft",
Removed: "removed",
} as const satisfies StatusMap;→ playground.ts:9:3 - error TS2322: Type '"removed"' is not assignable to type 'StatusValue'.
And the emitted JavaScript is just the object and the functions that use it:
const STATUS = {
Draft: "draft",
Published: "published",
Archived: "archived",
};
function describeStatus(status) {
if (status === STATUS.Draft) {
return "still being written";
}
if (status === STATUS.Published) {
return "visible to readers";
}
return "kept for history";
}
console.log(describeStatus(STATUS.Published));
console.log(Object.values(STATUS).join(", "));This is why the pattern won so much application code: you get a real object for runtime work, exact string values for the checker, and no enum object shape unless you deliberately choose one.
When string enums still earn a place
String enums are not wrong. They earn their place when the enum object itself is part of the API story:
- A framework, SDK, or generated client already exposes string enums and your code is matching that surface.
- A public package wants a named runtime object with members consumers can discover.
- A legacy codebase already standardized on string enums, and changing it would create churn without improving safety.
- A protocol uses stable string values, but the team wants an enum-shaped namespace more than it wants the const-object pattern.
Keep the bar higher for numeric enums because 8.1 showed their runtime and assignability sharp edges. If you choose an enum, prefer string values and treat it as a runtime design choice, not a default way to spell every closed set.
What the ecosystem pattern teaches
Do not read Sentry, tRPC, or Zod as a command to copy one exact internal style. Large projects mix techniques for compatibility, generated types, public APIs, and migration history. The useful lesson is the pattern those ecosystems made familiar:
- Sentry-style SDK code often crosses stringly external boundaries, so stable string values matter more than nominal enum identity.
- tRPC-style API code leans on value-first definitions: write the router-like runtime object, then let TypeScript infer from it.
- Zod-style validation starts with runtime schema values and derives TypeScript types from those values, because outside data must be checked at runtime.
The common thread is value first, type derived when possible. If a value must exist at runtime, write the runtime value and derive types from it. If no value is needed at runtime, use a literal union. If an enum-shaped runtime object is truly the API, use a string enum and own that choice.
The decision table
| Choice | Default use | Runtime cost | Best fit | | --- | --- | --- | --- | | Literal union | Start here | None | Closed choices used only by the checker | | Const array/object plus derived union | Use when code also needs the values | The array/object you wrote | Dropdowns, maps, labels, runtime lists, value-first APIs | | String enum | Use deliberately | Emitted enum object | Existing enum APIs, public runtime namespaces, compatibility surfaces | | Numeric enum | Rare in app code | Emitted enum object plus reverse mapping | Bit flags or legacy APIs where numbers are required |
That table is not dogma. It is a cost model. The question is always: do you need a runtime value, and if so, what shape should that runtime value have?
Section 8 gave you the constant toolkit: enums when a runtime enum is the point, as const when a literal should stay precise, satisfies when a value should be checked without losing inference, and const objects when values and types should share one source. Next section moves from constants into modules and compiler configuration.
Checkpoint
Answer all three to mark this lesson complete