skip to content

Designing the Discriminant

Choosing the tag field and member shapes so narrowing actually works: literal types, exactly one tag per union, and shapes where impossible combinations cannot be constructed. Interviewers often hand you a bag of optional booleans and ask you to refactor it into a proper union.

part ofTypeScriptoverview, primer and where to startread it →
on this pageshow

questions

4

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?

level: juniorimportance: must knowfreq 70%

answer

  1. one shared field, disjoint values
  2. the tag's type, not the tag's name
  3. wide primitives prove nothing
  4. present on every member, always required
  5. string, number, boolean or enum literal

basics

~20 s

The 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 s

Three 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 lines
typescript
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;
  }
  return s.side ** 2;
}

console.log(area({ kind: 'circle', radius: 2 }));
console.log(area({ kind: 'square', side: 3 }));

go deeper

for a junior

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.

for a middle

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.

for a senior

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.

for a principal

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

context

open as a page

You are handed the TypeScript type `{ isLoading: boolean; data?: User[]; error?: Error }` used to represent the state of an async request, and asked to make illegal states unrepresentable. How would you redesign it, and what does the change buy the call sites?

level: seniorimportance: must knowfreq 62%

basics

~20 s

Replace the flag bag with a union tagged by a status literal — idle, loading, success carrying required data, error carrying required error. Combinations like loading-with-an-error stop being constructible, and call sites lose their optional-field guesswork.

open as a page

In TypeScript, this code fails to compile — why, and what are the ways to fix it? ```ts type Shape = { kind: 'circle'; r: number } | { kind: 'square'; size: number }; const c = { kind: 'circle', r: 1 }; const s: Shape = c; ```

level: middleimportance: should knowfreq 52%

basics

~10 s

Inference widens the object's kind property to string, and string is not assignable to the literal type 'circle'. Fix it by annotating the variable as Shape, adding as const, or using satisfies Shape.

open as a page

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?

level: principalimportance: nice to knowfreq 28%

basics

~20 s

Pick 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.

open as a page