In TypeScript, you are choosing the discriminant for a union whose values are also serialized as JSON and exchanged with other services. What drives your choice of tag name and tag values, and what do you commit to by making that choice?
answer
- the tag survives erasure — it is on the wire
- one word for tags, used everywhere
- a tag has exactly one job
- numbers are unreadable in a payload
- closed set: who adds the next variant?
basics
~20 sPick one conventional, required field name that carries no data of its own, give it readable string-literal values that are unique within the union, and treat those values as a published contract: renaming one is a breaking change for every producer and consumer.
solid answer
~60 sThree commitments. **Name**: a single conventional field across the codebase — `type`, `kind` or `status` — that exists purely to tag, never a field that also carries domain data such as an id or a code. Consistency matters more than which word wins, because readers and codemods key on it. **Values**: readable string literals, unique inside the union, chosen so they mean something in a log line. I avoid numeric tags for a serialized contract because a number tells you nothing when you are staring at a payload, and numeric enum members shift if someone reorders the declaration. **Cardinality**: one tag per union. If two independent axes exist, model two fields or nest a union inside a member — do not expect the compiler to intersect two checks into one member. What you commit to is stability: because the tag is a real runtime value crossing a boundary, the literal set is part of the wire contract, and a consumer with a closed union meets an unknown tag the moment a producer adds a variant.
code
typescript · 18 lines// one tag per level: the branch member nests its own tagged union
type Expr =
| { kind: 'literal'; value: number }
| { kind: 'binary'; op: { kind: 'add' } | { kind: 'mul' }; left: Expr; right: Expr };
function evaluate(e: Expr): number {
if (e.kind === 'literal') return e.value;
const l = evaluate(e.left);
const r = evaluate(e.right);
return e.op.kind === 'add' ? l + r : l * r;
}
console.log(evaluate({
kind: 'binary',
op: { kind: 'mul' },
left: { kind: 'literal', value: 6 },
right: { kind: 'literal', value: 7 },
}));go deeper
Know that the tag is an ordinary property that really exists in the emitted JavaScript and in any JSON you send, so its name and values are not just a compile-time detail.
Explain why a data-carrying field such as an id or a status code makes a poor discriminant — its value set is open — and why string literals beat numeric enum members whose values depend on declaration order.
Argue a concrete convention for the codebase: one tag name, required, readable values unique within each union, and one discriminant per union with nesting when a second axis appears.
Name the commitment a closed union makes and who it binds. Say what happens when a producer ships an unknown tag, and where you would trade the compiler's exhaustiveness leverage for an open extension point instead.
## The tag is the one part of the type that is real Everything else in a discriminated union is erased at compile time: the member types, the union itself, all the narrowing. The tag is not. It is an ordinary property with an ordinary value, it is what gets written into JSON, and it is what a comparison reads at runtime. That is why tag choice is a design decision with consequences outside the type checker, and why it is worth deliberating rather than defaulting. ## Choosing the name **Pick one word and use it everywhere.** `type`, `kind`, `status`, `variant` — the arguments for each are weak, and the argument for consistency is strong: a codebase where every union tags on `kind` is greppable, teachable, and safe to codemod. Reserve a second word only when it genuinely reads better for a lifecycle (`status: 'pending' | 'settled'`) versus a shape (`kind: 'circle' | 'square'`). **Never overload a data field as the tag.** An `id`, an HTTP status number, a `code` — these carry information for other reasons, their value sets are open, and the day a new value appears the union silently stops covering reality. A tag should have exactly one job. **Keep it required and non-optional.** An optional tag adds `undefined` to the property type in every member and forces every consumer to handle a state that means nothing. **Watch for collisions with the surrounding contract.** A field named `type` inside a payload that is itself wrapped by an envelope which also uses `type` produces confusing logs and awkward mapping code. If the surrounding contract already spends the obvious word, spend a different one. ## Choosing the values **Readable strings beat numbers across a boundary.** `{"kind":"payment.refunded"}` is self-describing in a log, a queue browser, or a bug report; `{"kind":3}` is a lookup exercise. Numeric enum members carry an extra hazard: their values come from declaration order unless every member is explicitly initialised, so reordering the declaration silently changes what goes on the wire. **Unique within the union, and only within it.** Two members sharing a tag value cannot be told apart, and the narrowed type stays a union of both. Across two *different* unions, reuse is harmless — they are separate types — but reuse across unions that ever get mixed into one envelope will bite. **Namespace when producers are plural.** If several teams contribute variants to one envelope, a prefixed value (`billing.invoice_created`) keeps the value space collision-free without central coordination on every addition. **Treat the literal set as published.** Renaming `'ok'` to `'success'` is a one-line change in the type and a breaking change on the wire. Type-level refactors are usually free; this one is not, precisely because the tag survives erasure. ## One tag per union A union discriminates on a single property. Designs that hope for "when `mode` is `'edit'` **and** `source` is `'remote'`" are asking two independent checks to jointly select a member, and the model does not compose that way in general. Two options when two axes exist: ```ts // axes are genuinely independent -> keep them independent type Editor = { mode: 'edit' | 'view'; source: 'local' | 'remote' }; // axes are correlated -> nest, so each level has one tag type Node = | { kind: 'leaf'; value: number } | { kind: 'branch'; op: { kind: 'add' } | { kind: 'mul' }; children: Node[] }; ``` If the combinations are few and each really is a distinct shape, flattening into one tag with combined names (`'edit-remote'`, `'view-remote'`) is legitimate — but it scales as the product of the axes, so it earns its keep only when most combinations are impossible anyway. ## What a closed union commits you to This is the tradeoff a principal is expected to name. A discriminated union is a **closed** set: the compiler's leverage — exhaustive handling, a compile error when a variant is added — comes precisely from the promise that these are all the variants. That promise holds inside your codebase. It does not hold for values that arrive from elsewhere, where a producer may ship a new tag before you deploy. So decide, explicitly, what happens at the boundary. Either the contract guarantees consumers see only known tags (feasible when the same team deploys both sides), or the consumer's union needs a designated way to represent "a variant I do not know", so an unfamiliar payload degrades rather than crashes. Deciding this at design time is cheap; discovering it in production is not. A related commitment: because adding a member makes every exhaustive consumer fail to compile, the union works best where that failure is a *good* signal — inside one repo, or across repos you release together. Where third parties extend the variant set on their own schedule, an open extension point serves better than a closed union, and you accept weaker checking in exchange.
- When does a discriminated union stop being the right model?When the variant set is open — extended by third parties or other teams on their own release schedule. A closed union's payoff is the compile error you get when a variant appears, which is a benefit inside one release unit and an obstacle across many. It also stops paying when members differ in no fields at all, only in behaviour: then you are tagging for a dispatch that a plain map already gives you.
- Is it a problem if two different unions in the codebase use the same tag value, like 'error'?Not by itself — they are separate types and narrowing is per union. It becomes a problem when values from both unions can end up in the same envelope or the same log stream, because then a human or a shared handler cannot tell which contract a payload belongs to. Namespacing the values, or tagging the envelope as well as the payload, resolves it.
- You inherit a union tagged on a numeric enum that already ships in production payloads. What do you do?Do not renumber the values — the numbers are the contract. Pin every enum member to an explicit number so nobody's reordering shifts them, then improve readability at the edges: map to a string tag when logging, and if you ever version the payload, introduce the string tag as a new field alongside rather than replacing the numeric one in place.
saying these in an interview costs you the question
- Uses an id or HTTP status code as the discriminant
- Assumes the compiler enforces the tag on incoming JSON
- Renames tag values freely as a type-level refactor
- Tries to discriminate on two fields at once
- Picks numeric tags for readability of the wire format