You need a closed set of order states in TypeScript that is checked at compile time and can also be listed at runtime, without declaring an enum. How do you model it, and what do you give up?
answer
- object first, type derived from it
- the assertion keeps the literals narrow
- index the object type by its own keys
- one name, two declaration spaces
- nominality is what you traded away
basics
~20 sDeclare a frozen-shape object with as const for the runtime side, then derive the type from it with an indexed access over its own keys. You get the same member-style call sites and an iterable object, and you lose the enum's nominal behaviour.
solid answer
~50 sThe standard pattern is an `as const` object plus a type derived from it: `const OrderState = { Draft: 'draft', Paid: 'paid' } as const;` and `type OrderState = (typeof OrderState)[keyof typeof OrderState];`. The two declarations share a name legitimately, since one lives in the value space and one in the type space, so `OrderState.Paid` and `state: OrderState` both read exactly like the enum version — which makes replacing an enum mostly mechanical. `Object.values(OrderState)` gives you the members for iteration or validation, and because it is a plain object literal there is no reverse-mapping noise in it. The emit is just the object; the assertion and the type disappear, so it survives type-stripping builds. What you give up is the nominal behaviour: a bare `'paid'` is now assignable, there is no auto-numbering, and there is no name lookup unless you build one. External input still needs a runtime check — the type alone never proves anything.
code
typescript · 19 linesexport const OrderState = {
Draft: 'draft',
Paid: 'paid',
Shipped: 'shipped',
} as const;
export type OrderState = (typeof OrderState)[keyof typeof OrderState];
const states: readonly OrderState[] = Object.values(OrderState);
function isOrderState(value: unknown): value is OrderState {
return typeof value === 'string' && (states as readonly string[]).includes(value);
}
const fromApi: unknown = JSON.parse('"paid"');
if (isOrderState(fromApi)) {
const state: OrderState = fromApi;
console.log(state, OrderState.Draft, states);
}go deeper
Know the shape of the pattern: an object declared with as const, plus a type derived from it, used exactly the way you would have used an enum member.
Be able to explain each piece — why as const keeps the literals narrow, what typeof lifts into the type space, and what indexing by keyof produces — rather than reciting the incantation.
Justify the choice in context: derived-from-one-source values, clean iteration for validation and UI, plain-data emit, and an explicit statement of the nominality you traded away.
Decide when the set is worth a runtime object at all, and set the house pattern so packages do not each invent their own way of exposing a closed set to consumers.
## The pattern ```ts export const OrderState = { Draft: 'draft', Paid: 'paid', Shipped: 'shipped', } as const; export type OrderState = (typeof OrderState)[keyof typeof OrderState]; // => 'draft' | 'paid' | 'shipped' ``` Three pieces are doing work. The `as const` assertion keeps the property types as the literals `'draft' | 'paid' | 'shipped'` instead of widening them to `string`, and makes the properties `readonly`. `typeof OrderState` lifts the value into the type space. Indexing that object type by `keyof typeof OrderState` produces the union of all its property types — the member union you want. Declaring a `const` and a `type` with the same name is not a trick or a collision: TypeScript keeps values and types in separate declaration spaces, so `OrderState` refers to the object in value positions and to the union in type positions. That is what lets the pattern read like an enum at every call site. ## What you get **Enum-shaped usage.** `OrderState.Paid` still works, so switching an existing enum over is largely a mechanical edit of the declaration, not of the call sites. ```ts function advance(state: OrderState): OrderState { return state === OrderState.Draft ? OrderState.Paid : state; } ``` **Runtime enumeration.** `Object.values(OrderState)` is typed as the union's array and contains exactly the three strings — no reverse-mapped entries, unlike a numeric enum object whose values array also contains the member names. ```ts const states: readonly OrderState[] = Object.values(OrderState); function isOrderState(value: unknown): value is OrderState { return typeof value === 'string' && (states as readonly string[]).includes(value); } ``` That guard is the boundary check external data needs, and it is derived from the single source of truth: add a member to the object and the union, the values array and the guard all follow automatically. **Cheap, ordinary emit.** The compiled output is `export const OrderState = { Draft: 'draft', Paid: 'paid', Shipped: 'shipped' };` — a plain data literal, easy for a bundler to analyse and drop if unused, and valid syntax under builds that only strip types. ## What you give up **Nominality.** `const s: OrderState = 'paid'` now compiles. If part of the value of the enum was preventing an unrelated string from being used as an order state, this pattern does not give you that, and you would want a branded type instead. Note that the important protection remains: a value typed `string` is still rejected, so unvalidated input cannot flow in silently. **Auto-numbering and name lookup.** There is no implicit `0, 1, 2` assignment and no built-in value-to-name map. If you want a display label you write a second object, which many teams consider clearer anyway because the label is now explicit rather than a side effect of the identifier's spelling. **Two declarations instead of one.** Readers unfamiliar with the pattern have to be told why a `const` and a `type` share a name. It is a one-time cost and worth a comment in a shared package. ## Choosing between this and a bare union If you never need the values at runtime, do not build the object — `type OrderState = 'draft' | 'paid' | 'shipped'` is simpler and emits nothing at all. Reach for the `as const` object precisely when you need to iterate the members, validate against them, build a lookup, or render them in a dropdown. The rule of thumb: bare union for a pure constraint, `as const` object when the set itself is data. ## Interview framing Write the two declarations, point at the indexed access as the mechanism that derives one from the other, then be explicit about the trade — you traded the enum's nominality for plain-data emit and derivable runtime access. Volunteering the boundary guard shows you understand the type never validates anything on its own.
- Why is the `as const` assertion essential to the pattern?Without it the property types widen to `string`, so the derived indexed access collapses to `string` and the union constrains nothing. The assertion keeps each property at its literal type and marks them readonly, which is what makes the derived union meaningful.
- Isn't declaring a const and a type with the same name a name collision?No. TypeScript resolves identifiers in the value space and the type space separately, so the object answers in expressions and the union answers in annotations. It is the same mechanism that lets a class be both a constructor value and an instance type, and it is what keeps the call sites identical to the enum version.
- When would you keep a plain union and skip the object entirely?When nothing in the program needs the members at runtime. A bare union emits nothing, has no import to maintain, and is one line. Build the object only when you actually iterate, validate, or map over the set — otherwise you have paid for a runtime value you never read.
- How do you keep the runtime object and the type from drifting apart?You do not maintain them separately — the type is derived from the object with an indexed access, so adding or removing a property updates the union automatically. Anything else you need, such as a validation list, should also be derived from the same object rather than written out a second time.
saying these in an interview costs you the question
- Writes the union by hand alongside the object so the two can drift
- Omits as const, leaving the properties widened to string
- Thinks the derived union prevents an arbitrary string from entering at runtime
- Believes the same-name const and type are a name collision
- Assumes the pattern preserves the enum's protection against mixing