as const plus a lookup object as a lighter enum
Published May 18, 2027
export const Status = { Pending: "pending", Active: "active", Done: "done" } as const;
export type Status = (typeof Status)[keyof typeof Status];
export const describe = (status: Status): string => {
switch (status) {
case Status.Pending: return "waiting to start";
case Status.Active: return "in progress";
case Status.Done: return "finished";
}
};What
Status is declared twice — once as a const object with as const, once as a type derived from that object's values — and both declarations share the same name. describe then works exactly like it would against a real TypeScript enum, both in the type it accepts and the Status.Pending-style access to its members.
Why it matters
TypeScript's enum compiles to a real runtime object (or, for const enum, gets inlined, with cross-file caveats), generates a reverse mapping for numeric enums that's rarely used and is mostly dead code, and behaves differently enough from ordinary objects that it's a persistent source of TypeScript-vs-JavaScript friction — structural typing mostly doesn't apply to enum members the way it does everywhere else in TS. An as const object plus a derived union type gets you the same "named set of related literal values" without any of that: it's a plain object at runtime, with plain object semantics, and the type is just a string literal union under the hood — nothing enum-specific for other tools or teammates to learn.
How it works
as constfreezesStatus's inferred type down to the literal values"pending" | "active" | "done"rather than widening each property tostring— without it,(typeof Status)[keyof typeof Status]would just bestring.- The
type Statusdeclaration and theconst Statusdeclaration coexist under the same name because TypeScript keeps separate namespaces for types and values — the same mechanism that lets aclassbe used as both a type and a constructor. describe's parameter typeStatusrefers to the type declaration;Status.Pendinginside the function body refers to the value declaration. TypeScript resolves each use from context.- Because the union is a plain string literal union, it composes with everything else in the type system — narrowing,
Record<Status, ...>, template literal types — the same way any other literal union would.
Gotchas
- Nothing prevents someone from writing
describe("pending")directly instead ofdescribe(Status.Pending)— the type accepts the raw literal string too, sinceStatusthe type is just"pending" | "active" | "done", not a nominal type tied to theStatusobject. Object.freeze(Status)is a separate, runtime-only concern fromas const.as constonly affects the type TypeScript infers; without an actualObject.freeze,Status.Pending = "whatever"still mutates the object at runtime, unnoticed by the type checker after the initial assignment.
Related
enum— the built-in alternative this replaces; still reasonable for numeric auto-incrementing members or bitflag-style enums, whereas constobjects offer no equivalent shorthand.satisfies— often paired withas constwhen the lookup object also needs to be checked against some external shape, likeRecord<string, SomeInterface>, without losing the literal inferenceas constprovides.