Topic 4.5
Discriminated Unions
In one line
A discriminated union is a union of object types that all share one property with a different literal value in each (the tag). Checking the tag tells TypeScript exactly which object you have, so every branch gets precisely the fields that exist.
Think of it like this
Parcels at a post office each carry a coloured sticker: red for "fragile", blue for "letter", green for "heavy". The clerk doesn't open the parcel to find out what's inside; the sticker says which kind it is and therefore which fields on the form to fill in. A discriminated union puts that sticker on every object type, and the compiler reads it.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Discriminated union
- A union of object types sharing a tag property with a different literal value in each.
- Discriminant / tag
- The shared property (like
kindorstatus) whose literal value says which member you have. - Variant
- One member of the union: one of the shapes the data can take.
- Payload
- The extra fields a variant carries besides its tag, like
datain the success variant. - Illegal state
- A combination of values that should never exist, like a request that has succeeded and also failed.
- Sum type
- The general computer-science name for "one of several variants". A discriminated union is TypeScript's sum type.
- Switch
- A statement that picks a branch by comparing one value with several
caselabels.
Step by step
01Start with the three states
A page loads a list. It is either still loading, or it succeeded with the list, or it failed with a message. Those three are the only truths.
Write one object type for each state, and give each a status tag with its own literal. Join them with |.
type Request =
| { status: "loading" }
| { status: "success"; data: string[] }
| { status: "error"; message: string; retryable: boolean };02Check the tag, get the right fields
req.status === "error" narrows to the error member. Inside the branch, req.message and req.retryable exist; req.data doesn't.
After the loading and error checks have returned, only success is left, so req.data is a plain string[], no undefined, no extra check.
03switch reads like a table
switch (shape.kind) with one case per tag is the usual layout. In each case, the shape is exactly that member. Reading the code feels like looking things up in a table.
Topic 4.6 adds the last piece: making the compiler tell you when a case is missing.
04Why not one object with optional fields?
The "bag of optionals" { status: ...; data?: ...; message?: ... } lets bad data exist and makes you re-check what the tag already told you.
The union has fewer legal values: a success must have data. The compiler enforces this at every place that creates one, and the checker also rejects mixed-up objects such as { status: "success", message: "oops" }.
05Constructing values
When you create a value, TypeScript picks the member by the tag you wrote and checks the other fields against it. A wrong field is reported as an unknown property of that member.
Helper functions per variant (success(data), fail(message)) make creation short and keep tags spelled in one place.
Try it yourself
- 1
Add a variant
In the first example add
| { kind: "triangle"; base: number; height: number }toShape. Look at the Types panel before adding acase.You'll see an error on
areaitself:Function lacks ending return statement. Topic 4.6 is about turning that into a precise to-do list. - 2
Break the tag
In the second example, change the tag of the success member to
status: string. What happens toreq.dataafter the two early returns?The tag is no longer a literal, so the checks no longer separate the members. The
status === "error"branch now keeps the success member too (astringcould be"error"), soreq.messageis an error, andreq.datais an error as well. - 3
Write a helper
Add
function fail(message: string): Request { return { status: "error", message, retryable: false }; }and callrender(fail("x")). Removeretryablefrom the helper and read the error.
Code & diagrams
In the square case the hover shows only the square member. Notice area() has no default and returns number: TypeScript sees that every kind is covered, so no case falls through (Topic 4.6 explains this).
Console
(nothing was printed)Expected console output
13 12After the two early returns only the success member is left, so req.data is a real array. first is string | undefined because the course turns on noUncheckedIndexedAccess (an array read might be out of range). The last line shows an illegal state being rejected.
Console
(nothing was printed)Expected console output
Loading...
Got 2 items, first: a
Timeout (retry)Loose can't connect status to data, so even after the success check, data may be undefined. In the second function the tag is saved in a const first, and TypeScript still narrows p through that alias.
Console
(nothing was printed)Expected console output
5 whyBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Read a field of the wrong variant
At the end of the first example, add function broken(shape: Shape) { return shape.radius; } (on separate lines).
Myth vs fact
Myth
A discriminated union needs a property named type or kind.
Fact
Any name works, as long as it is the same in every member and typed as a unique literal in each.
Myth
Optional fields model variants well enough.
Fact
They allow illegal combinations and force repeated checks. A union of exact shapes says what is legal.
Myth
The tag has to be a string.
Fact
Numbers, booleans, null and undefined work too, as long as each member's tag is a literal type.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
The checker identifies a discriminant property as one whose type in at least one union member is a unit (literal) type and which differs between members. Narrowing by it filters the union to members whose property type is comparable to the checked value. If a member's tag is a plain
string, it is not a unit type and that member survives every check. - ▸
Since TypeScript 4.6, control-flow narrowing also works when you destructure the tag and another field together from a discriminated union (
const { kind, payload } = action, where every member has apayloadof its own type): checkingkindthen narrowspayload. The field must exist in every member, otherwise destructuring it is an error (Property 'side' does not exist on type …). Objects built from JSON are plain data, so a tag is also easy to serialise: nothing about this pattern needs classes. - ▸
Large discriminated unions are fast to narrow (a lookup by tag), unlike unions narrowed by
inor structural comparison. When designing a message protocol or an AST (abstract syntax tree, the tree a compiler builds from code) with dozens of variants, a tag keeps the checker quick and the errors readable.
Remember this
- 1
The problem this solves: Checking object shapes by guessing which properties exist is fragile and grows messy with each new shape. A single tag property makes the kind obvious and checkable. A discriminated union (also called a tagged union or sum type) is a union of object types where every member has a property of the same name (the discriminant or tag) whose type is a different literal in each member. For example
{ kind: "circle"; radius: number } | { kind: "square"; side: number }. The tag iskind; its values are"circle"and"square". - 2
When you check the tag, the compiler knows the whole object.
if (shape.kind === "circle")narrowsshapeto the circle member, andshape.radiusis allowed. In the other branchshape.sideis. The same works withswitch (shape.kind), onecaseper tag value. No casts, noinchecks, no guessing: the tag is the single question that identifies the member. - 3
The tag must be a literal type (
"circle",42,true,nullorundefinedall work), not a widestring. That's why Topic 4.2 comes first: if you writekind: stringin a member, the compiler can't tell members apart and the tag stops narrowing. Common tag names arekind,type,statusandtag; pick one convention per codebase. - 4
The most valuable use is modelling states. A network request is loading, or succeeded with data, or failed with an error message, never a mix. Write it as three members:
{ status: "loading" },{ status: "success"; data: string[] }and{ status: "error"; message: string }. Now it is impossible to represent a success with no data, or a loading state that has an error. This idea, "make illegal states unrepresentable", removes whole classes of bugs. - 5
Compare the common beginner version: one object with optional fields,
{ status: "loading" | "success" | "error"; data?: string[]; message?: string }. The compiler cannot connectstatuswith the fields, so after checkingstatus === "success",datais still possiblyundefinedand you need a pointless extra check. Worse, nothing stops you creating asuccesswith no data. The discriminated union carries the relationship in the types. - 6
Discriminated unions are what you meet in real code: Redux and
useReduceractions ({ type: "add", amount }, see the React course), API results ({ ok: true, value } | { ok: false, error }), parsed tokens in a compiler, UI events, shape libraries and message protocols between a browser and a worker. When you design data that has "variants", reach for this first. - 7
Rules that make the narrowing work. Every member needs the tag property. Tag values should be unique per member (if two members share a tag value, checking that value leaves both of them). You can tag by a
boolean(ok: trueandok: false). Narrowing also works if you first save the tag in aconst(const { kind } = pthenif (kind === "a")), as the third example shows: since TypeScript 4.4 the checker follows such aliases when the checked value is aconst, areadonlyproperty or a parameter you never reassign.
Explain it without notes
What is a discriminated union and what problem does it solve?
Why is a { status; data?; message? } object worse than a discriminated union for a request state?
What conditions must hold for the tag to narrow the union?
Where do you meet discriminated unions in real projects?
Practice
Model a payment method as a union of { method: "card"; last4: string }, { method: "upi"; id: string } and { method: "cash" }. Write function describe(p: Payment): string with a switch.
Convert type Result = { ok: boolean; value?: number; error?: string } into a discriminated union and write function unwrap(r: Result): number that returns the value or throws the error text.
Write a helper function success(data: string[]): Request and function loading(): Request for the Request type of this topic, so callers never write the tag text themselves.
Trade-offs
- ↔
A discriminated union adds a tag property and one type per variant. For data with only one shape it is overhead; for anything with real variants it pays back immediately.
- ↔
Adding a variant forces every
switchto be updated. That is the point (Topic 4.6), but in a large codebase it means a wide change; keep variants stable and put changing details inside a payload.
Done when you can
I can write a discriminated union with a literal tag.
I can narrow it with
ifand withswitch.I can explain why the tag must be a literal type.
I can model loading, success and error as three variants.
I can say why optional fields are a weaker model than variants.