In TypeScript, what must be true of a property for the compiler to treat it as a union's discriminant, so that comparing that property narrows the value to a single member?
answer
- one shared field, disjoint values
- the tag's type, not the tag's name
- wide primitives prove nothing
- present on every member, always required
- string, number, boolean or enum literal
basics
~20 sThe tag must exist on every union member and be typed as a literal — a string, number, boolean or enum-member literal — with a different value per member, so an equality check selects exactly one member.
solid answer
~50 sThree things have to hold. First, the property must be declared on **every** member of the union — a property that only some members have cannot even be read off the union value without an error. Second, its type in each member must be a literal (unit) type: `'circle'`, `404`, `true`, an enum member, or `null`/`undefined` — not `string`, `number` or `boolean`. Third, the values must actually distinguish the members. Given `{ kind: 'circle'; r: number } | { kind: 'square'; size: number }`, writing `if (s.kind === 'circle')` lets the compiler drop every member whose tag cannot be `'circle'`, so `s.r` becomes accessible in that branch. A member may carry a union of literals (`kind: 'a' | 'b'`) and still discriminate. And note the tag is a *real* property in the emitted JavaScript — types are erased, but the tag is the runtime value the check reads.
code
typescript · 13 linestype Shape =
| { kind: 'circle'; radius: number }
| { kind: 'square'; side: number };
function area(s: Shape): number {
if (s.kind === 'circle') {
return Math.PI * s.radius ** 2;
}
return s.side ** 2;
}
console.log(area({ kind: 'circle', radius: 2 }));
console.log(area({ kind: 'square', side: 3 }));go deeper
Be able to write a two-member tagged union from memory and say why kind: 'circle' narrows while kind: string does not. Naming the field is the easy half; the literal type is the point.
Explain unit types — string, number, boolean, enum-member, null and undefined literals — and why reading a property off a union requires it on every constituent. Show the ?: undefined trick for a member that has no natural tag.
Demonstrate judgment about tag choice on real models: a required field with a closed, meaningful value set, never a data field, one tag per union. Be ready to explain how a widened tag turns a clean union into a silent no-op.
Own the convention. Argue for a single tag name across the codebase, tag values that stay stable because they cross serialization boundaries, and a rule against discriminating on two axes at once — then say what the team gives up by making the variant set closed.
## The idea A **discriminated union** (also called a tagged union) is a union of object types that all share one property — the *discriminant* or *tag* — whose type is a different literal in each member. Because the tag values are disjoint, a single equality comparison tells the compiler which member it is holding, and control-flow analysis narrows the value for the rest of that branch. ```ts type Shape = | { kind: 'circle'; radius: number } | { kind: 'square'; side: number }; function area(s: Shape): number { if (s.kind === 'circle') { return Math.PI * s.radius ** 2; // s is the circle member here } return s.side ** 2; // s is the square member here } ``` ## Requirement 1 — the tag is on every member Reading a property off a union value is only allowed when *all* constituents declare it. If one member omits `kind`, `s.kind` is a compile error before narrowing ever gets a chance. This is why "one shared tag, present everywhere" is the design rule rather than a stylistic preference. There is a useful escape hatch when one member genuinely has no tag: give it the tag explicitly typed as `undefined`. ```ts type Result = | { error: string; data?: undefined } | { error?: undefined; data: number }; ``` `undefined` is itself a unit type, so `if (r.error !== undefined)` discriminates cleanly, and the member that lacks a field says so in the type rather than leaving a hole. ## Requirement 2 — the tag's type is a literal, not a wide primitive TypeScript calls `'circle'`, `42`, `true`, `null`, `undefined` and enum members **unit types**: types with exactly one value. A discriminant must be one of these (or a union of them) in each member. If any member declares `kind: string`, that member can hold *any* string, so `s.kind === 'circle'` proves nothing about it and the compiler keeps it in the narrowed set. The narrowing silently degrades — no error, just a union where you expected one member. Booleans work well because `boolean` is really the union `true | false`: ```ts type Response = | { ok: true; data: string } | { ok: false; error: Error }; function read(r: Response) { return r.ok ? r.data : r.error.message; } ``` A member may also carry a union of literals — `{ kind: 'circle' | 'ellipse'; ... }` — and comparing against either literal still selects it. ## Requirement 3 — the values distinguish the members If two members share the same tag value, checking it cannot separate them; the narrowed type stays a union of both. Tag values must be unique *within one union*. Reusing the same tag value across two different unions is fine — they are separate types — but reusing it inside one is a modelling bug that shows up as "why is this still a union?". ## One tag per union Discriminate on a single property. A design that expects the compiler to combine two independent fields ("when `mode` is `'edit'` and `source` is `'remote'`…") is fighting the model: each check narrows independently, and the cross-product of two axes rarely collapses to the member you want. If two axes are genuinely orthogonal, keep them as two fields (or two nested unions); if they are not orthogonal, flatten them into one tag with the combinations that actually exist. ## Why this works at all — the erasure angle Everything else in TypeScript's type layer disappears at compile time. The discriminant is the exception you rely on: the tag is an ordinary property with an ordinary runtime value, and `s.kind === 'circle'` is a plain JavaScript comparison that survives into the emitted code. Narrowing is not the compiler inserting a runtime check — it is the compiler *reading* a check you already wrote and drawing the type-level conclusion. That is precisely why discriminated unions are the recommended way to model variants: the type-level story and the runtime story are the same expression. ## What breaks in practice - The tag widens to `string` when an object literal is stored in a variable without an annotation — narrowing then fails at the assignment, not at the comparison. - The tag is optional (`kind?: 'circle'`), so `undefined` leaks into every branch. - The tag is a `number` field that also carries data (an id, a count) — data fields make poor tags because their values are not a closed, meaningful set. - Members are declared with a shared base type that types the tag as the wide union for all of them, so no member has a single literal. Pick a conventional tag name (`kind`, `type`, `status`), make it required, give it a string literal per member, and the compiler does the rest.
- One member of my union genuinely has no such field — must I invent a tag for it?You can, and usually should, but there is a lighter option: declare the field on that member explicitly typed as `undefined`, e.g. `{ data: number; error?: undefined }`. `undefined` is a unit type, so the property exists on every constituent and comparing against `undefined` discriminates. It also documents the absence in the type instead of leaving the shape ambiguous.
- Can a discriminant be a boolean rather than a string?Yes. `boolean` is the union `true | false`, and each is a unit type, so `{ ok: true; data: T } | { ok: false; error: Error }` narrows on `if (r.ok)` with no comparison needed. It reads well for exactly two variants; beyond two, a string tag scales, stays readable in logs, and leaves room for a third state without reshaping the type.
- Does a discriminated union cost anything at runtime compared with a plain object?Only the tag property itself — one extra field per object. The union type, the member types and all the narrowing are erased at compile time and emit nothing. The `kind === 'circle'` comparison is ordinary JavaScript you would likely have written anyway, so the runtime cost is a string comparison rather than anything the type system adds.
saying these in an interview costs you the question
- Claims the compiler checks the tag at runtime for you
- Types the tag as string and expects narrowing to work
- Thinks the tag must be named kind or type specifically
- Leaves the tag off one member and reads it anyway
- Reuses the same tag value in two members of one union