enum domains
rule
- build a closed string domain as a named
as constobject + 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
enumadds 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
StatusEnummembers when code needs the whole domain — parsing, exhaustive maps, defaults, iteration — not to make a single comparison look formal.
why the four names exist
| owner | responsibility |
|---|---|
StatusEnum | canonical members for comparison, defaults, maps, and switches. |
StatusEnumValues | ordered literal tuple for validators or UI enumeration. |
StatusEnumSchema | runtime parsing of external values. |
Status | inferred union for typed consumers. |
- one closed vocabulary with runtime validation.
- a plain string array
statusEnumlacks named members; a TypeScriptenumadds runtime semantics and interop behavior the contract does not need.
decision process
| candidate | failure | selected direction |
|---|---|---|
| import an enum owner for one comparison | indirection makes the simplest branch longer than its value | compare the direct string. |
| repeat raw strings across many callers | spelling and casing drift; searches cannot find every member use | compare StatusEnum.Member where the domain is enumerated. |
| create local aliases for imported members/types | one concept acquires multiple names | import and use the canonical owner directly. |
| accept an unknown value then default it | source contract drift becomes a false business state | parse and reject unknown members. |
use an enum keyword | introduces a distinct runtime representation without a need | use 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 placeconst statusValues = ["draft", "published", "archived"] as const;// but comparisons use raw strings everywherefunction 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 runsconst active = runs.filter(r => isPublished(r.status)); // ✓ updatedconst archivable = runs.filter(r => canArchive(r.status)); // ✓ updatedconst archived = await reconcileArchived(runs); // ✗ still searching for old value// grep "published" finds 1000+ matches (schema definitions, error messages, comments);// you miss the one inside reconcileArchivedgrep "published" src/
- raw strings hide valid values and make refactoring unsafe.
- a member change cannot reliably find every call site —
reconcileArchivedsilently 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 oncefunction 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.