10LOC
TypeScriptintermediate

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 const freezes Status's inferred type down to the literal values "pending" | "active" | "done" rather than widening each property to string — without it, (typeof Status)[keyof typeof Status] would just be string.
  • The type Status declaration and the const Status declaration coexist under the same name because TypeScript keeps separate namespaces for types and values — the same mechanism that lets a class be used as both a type and a constructor.
  • describe's parameter type Status refers to the type declaration; Status.Pending inside 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 of describe(Status.Pending) — the type accepts the raw literal string too, since Status the type is just "pending" | "active" | "done", not a nominal type tied to the Status object.
  • Object.freeze(Status) is a separate, runtime-only concern from as const. as const only affects the type TypeScript infers; without an actual Object.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, where as const objects offer no equivalent shorthand.
  • satisfies — often paired with as const when the lookup object also needs to be checked against some external shape, like Record<string, SomeInterface>, without losing the literal inference as const provides.