enum domains

rule

  • build a closed string domain as a named as const object + derived values tuple + named Zod schema.
  • never use TypeScript enum.
  • use the named members where code enumerates, validates, maps, or switches over the domain.
  • compare the direct string where a branch needs only one known value.

why

  • raw string literals hide the valid set; grepping "draft" finds dozens of unrelated strings.
  • TypeScript enum adds an unneeded runtime representation and interop friction across package boundaries.
  • the trio gives one canonical vocabulary: named members for comparison/maps, a parse-time-verifiable tuple, Zod validation at every untrusted boundary.
  • a spelling change becomes a single grep-friendly line.

shape

export const StatusEnum = { Draft: "draft", Published: "published" } as const;
export const StatusEnumValues = [StatusEnum.Draft, StatusEnum.Published] as const;
export const StatusEnumSchema = z.enum(StatusEnumValues);
export type Status = z.infer<typeof StatusEnumSchema>;
  • prefer the shortest correct call site: if (event === "draft") beats importing an enum owner for one comparison.
  • use StatusEnum members when code needs the whole domain — parsing, exhaustive maps, defaults, iteration — not to make a single comparison look formal.

why the four names exist

ownerresponsibility
StatusEnumcanonical members for comparison, defaults, maps, and switches.
StatusEnumValuesordered literal tuple for validators or UI enumeration.
StatusEnumSchemaruntime parsing of external values.
Statusinferred union for typed consumers.
  • one closed vocabulary with runtime validation.
  • a plain string array statusEnum lacks named members; a TypeScript enum adds runtime semantics and interop behavior the contract does not need.

decision process

candidatefailureselected direction
import an enum owner for one comparisonindirection makes the simplest branch longer than its valuecompare the direct string.
repeat raw strings across many callersspelling and casing drift; searches cannot find every member usecompare StatusEnum.Member where the domain is enumerated.
create local aliases for imported members/typesone concept acquires multiple namesimport and use the canonical owner directly.
accept an unknown value then default itsource contract drift becomes a false business stateparse and reject unknown members.
use an enum keywordintroduces a distinct runtime representation without a needuse literal object, tuple, schema, and inferred union.

verification

  • review raw member literals only in code that enumerates or maps the domain.
  • exercise schema parsing for every allowed member plus one unknown member.
  • check maps and switches are exhaustive over Status.
  • inspect edited literals for duplicate object keys or duplicate imports.

good and bad

bad

// status enum lives in one place
const statusValues = ["draft", "published", "archived"] as const;
// but comparisons use raw strings everywhere
function isPublished(status: string): boolean {
return status === "published"; // call site 1
}
function canArchive(status: string): boolean {
return status === "published"; // call site 2
}
async function reconcileArchived(runs: WorkflowRun[]) {
// refactor: published → published_approved (new legal requirement)
return runs.filter(r => r.status === "published"); // call site 3: MISSED
}
// reconcileArchived still looks for old "published", missing the new runs
const active = runs.filter(r => isPublished(r.status)); // ✓ updated
const archivable = runs.filter(r => canArchive(r.status)); // ✓ updated
const archived = await reconcileArchived(runs); // ✗ still searching for old value
// grep "published" finds 1000+ matches (schema definitions, error messages, comments);
// you miss the one inside reconcileArchived
grep "published" src/
  • raw strings hide valid values and make refactoring unsafe.
  • a member change cannot reliably find every call site — reconcileArchived silently misses records, leaving stale archived workflows.

good

export const StatusEnum = { Draft: "draft", Published: "published", Archived: "archived" } as const;
export const StatusEnumValues = [StatusEnum.Draft, StatusEnum.Published, StatusEnum.Archived] as const;
export const StatusEnumSchema = z.enum(StatusEnumValues);
// when the value changes, it changes once
function isPublished(status: string): boolean {
return status === StatusEnum.Published; // call site 1
}
function canArchive(status: string): boolean {
return status === StatusEnum.Published; // call site 2
}
async function reconcileArchived(runs: WorkflowRun[]) {
return runs.filter(r => r.status === StatusEnum.Archived); // call site 3: UNIFIED
}
// grep for the enum member finds exact uses (the 2 callers checking published)
grep "StatusEnum\\.Published" src/
// rename: Published → PublishedApproved: "published_approved"
// TypeScript compilation fails at every old reference to StatusEnum.Published
// you must update each one deliberately and see what logic it represents
  • one enum-like object is the source of truth: every reference grep-friendly and TypeScript-enforced.
  • a refactor is visible and safe — change the object; compilation fails at every call site needing an update.

search pages

go to any page