skip to content

Your TypeScript monorepo models closed value sets inconsistently — some packages use enums, others string-literal unions. How would you decide on a standard, and what would you do about numeric enums whose values are already stored in the database and sent on the wire?

level: principalimportance: should knowfreq 30%

answer

  1. constraints decide it, not taste
  2. serialization boundary is the first question
  3. stored values are frozen
  4. type change and data change are separate
  5. migrate on touch, enforce on new code

basics

~20 s

Decide from constraints, not taste: does the value cross a serialization boundary, is it needed at runtime, and does the build require erasable syntax. Then change the type layer while freezing the stored values, and migrate on touch rather than all at once.

solid answer

~60 s

I would write the standard as a decision rule rather than a ban. Three questions drive it. Does the value cross a serialization boundary? Then it must be a stable string, which rules out numeric enums whose meaning lives only in the declaration order. Does anything need the members at runtime — validation, iteration, a dropdown? If yes, an `as const` object; if no, a bare literal union that emits nothing. Does the build target type-stripping or `erasableSyntaxOnly`? Then enums are simply unavailable and the decision is made for you. For values already persisted, the stored representation is frozen — I would keep the exact same values and only change the type layer, so `enum Priority { Low = 1 }` becomes an `as const` object with `Low: 1`, no data migration, no wire change, and call sites untouched. Moving numbers to strings is a separate, data-migration-sized project justified only when you are already reshaping the format. Then enforce on new code, migrate on touch, and let a lint rule carry the convention rather than a review habit.

code

typescript · 15 lines
typescript
// Before: numeric enum whose values are already persisted
// enum Priority { Low = 1, High = 2 }

// After: same runtime values, same call sites, erasable emit
export const Priority = { Low: 1, High: 2 } as const;
export type Priority = (typeof Priority)[keyof typeof Priority];

const allowed: readonly number[] = Object.values(Priority);

export function toPriority(stored: number): Priority {
  if (allowed.includes(stored)) return stored as Priority;
  throw new Error(`Unknown stored priority: ${stored}`);
}

console.log(Priority.Low, toPriority(2));

go deeper

for a junior

Know that the choice has consequences beyond style — values that leave the process are a contract with other systems, so you do not change them casually.

for a middle

Be able to convert an enum to an as const object while keeping the values byte-identical, and explain why the call sites do not have to change.

for a senior

Show you can sequence the work: which enums are safe to change invisibly, which are embedded in stored data, and where the boundary translation belongs so only one module knows the mapping.

for a principal

Own the standard itself — the decision rule, the exception list, the enforcement mechanism and the success signals — and be explicit that the type change and the data-representation change are separately justified projects.

## Treat it as a decision rule, not a ban "No enums" is a rule people route around and argue about. A decision rule survives, because it names the constraints that actually differ between packages. **Does the value cross a serialization boundary?** If it appears in an HTTP payload, a queue message, a database column or a URL, the representation is a contract with systems you do not compile. It must be a stable, self-describing string. A numeric enum is the worst option here: the number carries no meaning in a log or a stored row, and the auto-numbering is positional, so inserting a member in the middle silently renumbers everything after it. That failure mode is not hypothetical and not detectable by the compiler. **Does anything need the members at runtime?** Validation at boundaries, rendering a select, building a lookup — those need a real value, so use an `as const` object with the derived union type. If the set is purely a compile-time constraint, use a bare literal union and emit nothing at all. **Does the build constrain syntax?** Pipelines that strip types instead of transforming them, and the compiler's own `erasableSyntaxOnly` flag, reject `enum` outright. If any target in the repo has that constraint, the answer is decided for every package that might be consumed by that target — this is where the choice stops being style and becomes portability. **Is preventing accidental mixing genuinely valuable here?** That is the one thing enums give you that a union does not. Where it matters — two identifier kinds that must never be confused — a branded type gives the same protection while staying erasable, so even this case rarely argues for an enum. ## The published-package angle An enum exported from a package is part of the *runtime* API surface, not just the type surface. Consumers cannot pull it in with a type-only import and still produce members, so it becomes a real dependency edge. Removing or renaming a member is a runtime breaking change for them, not a type change they can absorb with a version bump of their checker. Literal unions and `as const` objects keep those two surfaces separable, which matters more the more packages you publish. ## Handling values already in the data The governing constraint is that stored data is not yours to reinterpret. So separate two changes that look like one: **Change the type layer, freeze the values.** Replacing an enum with an `as const` object that carries the identical values is invisible outside the compiler: ```ts // before enum Priority { Low = 1, High = 2 } // after — identical runtime values, identical call sites export const Priority = { Low: 1, High: 2 } as const; export type Priority = (typeof Priority)[keyof typeof Priority]; ``` Nothing on the wire moves, no rows change, and `Priority.Low` still reads the same at every call site. As a bonus the numeric-enum assignability hole closes: a value typed `number` is not assignable to `1 | 2`, so the compiler now demands the boundary validation that was previously optional. Expect that to surface real errors — treat them as the point of the exercise, not as breakage. **Change the representation only deliberately.** Turning `1` into `'low'` in stored data is a migration: dual-write or a translation layer at the edge, a backfill, a read path that tolerates both, and a cleanup. Worth doing when the numbers are actively causing incidents or when you are already reshaping that payload; not worth doing as a side effect of a typing convention. Where numbers must stay on the wire, put the translation at the boundary — parse to a named string internally, serialise back to the number on the way out — so exactly one module knows the numeric mapping. ## Rolling it out Sequence by risk, not by package alphabet. Start with internal, non-serialised enums, where the change is invisible. Then shared packages, where the payoff in consumer coupling is largest. Leave enums embedded in wire formats until someone touches that format for another reason. Make the convention mechanical: a lint rule that flags new enum declarations with a documented exception list beats a review habit, because it survives turnover. Codemod the mechanical part — the declaration change is repetitive and the call sites do not move. And write down the exceptions honestly; a standard with no exceptions is a standard people ignore quietly. ## What to measure Define success before you start, or the effort drifts. Reasonable signals: no numeric values in new serialized contracts; every boundary parsing a closed-set field has a validation step; packages consumable by type-stripping builds. Bundle bytes are the weakest justification on its own — real if you are shipping many enums to browsers, but rarely the argument that decides it. ## What a strong answer sounds like Lead with the decision rule and its constraints, be explicit that stored values are frozen and that the type change and the representation change are separate projects, and finish on enforcement and sequencing. Insisting on a repo-wide rewrite, or waving away the persisted numbers, is what makes this answer fail.

  • A team argues their internal enum never leaves the process, so the standard should not apply. How do you respond?
    I would agree, and say so in the standard. Internal, non-serialised sets are the weakest case for changing anything, and forcing them wastes goodwill. The exceptions to name are build portability — if that package can be consumed by a type-stripping target, the enum blocks it — and the likelihood that today's internal value becomes tomorrow's payload field.
  • What would make you keep numeric values on the wire rather than migrating to strings?
    Cost and blast radius. If the numbers are already stored across large tables and consumed by systems outside my control, a migration buys readability at the price of a dual-write, a backfill and a long tail of tolerant readers. I would instead confine the mapping to one boundary module and revisit only when that contract is being reshaped anyway.
  • How do you stop the standard from decaying once the initial push is over?
    Encode it where the work happens: a lint rule for new declarations, the pattern in the package template, and the exception list in the same document. Reviews carry conventions only as long as the people who care stay. If it cannot be checked automatically, assume it will drift.
  • Replacing the enums surfaced a wave of new type errors. How do you handle that?
    Expect it and budget for it. Most will be places where a value typed number or string was flowing into an enum-typed slot unvalidated — real latent bugs the numeric-enum rule was hiding. I would fix them at the boundaries rather than by asserting them away, and I would stage the rollout so that wave lands package by package.

saying these in an interview costs you the question

  • Proposes a repo-wide rewrite of every enum at once
  • Changes stored numeric values to strings without a data migration
  • Argues the case purely on bundle bytes
  • Bans enums with no exception list and no rationale
  • Treats the new type errors as breakage rather than as latent bugs

context