Command Palette

Search for a command to run...

Hectal
PHASE 4Beginner ~26 min· topic 5 of 7

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 kind or status) 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 data in 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 case labels.

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

request.tswhole filets
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.

switch reads like a tablediagram
Rendering diagram…

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

    Add a variant

    In the first example add | { kind: "triangle"; base: number; height: number } to Shape. Look at the Types panel before adding a case.

    You'll see an error on area itself: Function lacks ending return statement. Topic 4.6 is about turning that into a precise to-do list.

  2. 2

    Break the tag

    In the second example, change the tag of the success member to status: string. What happens to req.data after 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 (a string could be "error"), so req.message is an error, and req.data is an error as well.

  3. 3

    Write a helper

    Add function fail(message: string): Request { return { status: "error", message, retryable: false }; } and call render(fail("x")). Remove retryable from the helper and read the error.

Code & diagrams

A shape union and a switch

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 12
Modelling a request: loading, success, error

After 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)
The bag of optionals is worse, and aliases still narrow

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 why

Break 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).

terminal
$ Types panel
── what you'll see ──
index.ts(21,16): error TS2339: Property 'radius' does not exist on type 'Shape'.
Property 'radius' does not exist on type '{ kind: "square"; side: number; }'.

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 a payload of its own type): checking kind then narrows payload. 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 in or 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. 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 is kind; its values are "circle" and "square".

  2. 2

    When you check the tag, the compiler knows the whole object. if (shape.kind === "circle") narrows shape to the circle member, and shape.radius is allowed. In the other branch shape.side is. The same works with switch (shape.kind), one case per tag value. No casts, no in checks, no guessing: the tag is the single question that identifies the member.

  3. 3

    The tag must be a literal type ("circle", 42, true, null or undefined all work), not a wide string. That's why Topic 4.2 comes first: if you write kind: string in a member, the compiler can't tell members apart and the tag stops narrowing. Common tag names are kind, type, status and tag; pick one convention per codebase.

  4. 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. 5

    Compare the common beginner version: one object with optional fields, { status: "loading" | "success" | "error"; data?: string[]; message?: string }. The compiler cannot connect status with the fields, so after checking status === "success", data is still possibly undefined and you need a pointless extra check. Worse, nothing stops you creating a success with no data. The discriminated union carries the relationship in the types.

  6. 6

    Discriminated unions are what you meet in real code: Redux and useReducer actions ({ 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. 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: true and ok: false). Narrowing also works if you first save the tag in a const (const { kind } = p then if (kind === "a")), as the third example shows: since TypeScript 4.4 the checker follows such aliases when the checked value is a const, a readonly property or a parameter you never reassign.

Explain it without notes

01

What is a discriminated union and what problem does it solve?

02

Why is a { status; data?; message? } object worse than a discriminated union for a request state?

03

What conditions must hold for the tag to narrow the union?

04

Where do you meet discriminated unions in real projects?

Practice

01

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.

02

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.

03

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 switch to 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 if and with switch.

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