← Concepts & practices
Concept Data modeling and type design

Discriminated unions

Which case is this?

You already write these. A fetch that is loading, failed, or loaded. A reducer action with a type. A webhook event whose fields depend on what happened. Let’s follow an import’s status line from a record full of optional fields to cases that carry exactly the data they need.

TypeScriptGo One import status, two implementations.

01 / The idea

A record with optional fields is a fair first draft.

You’re building the status line for a spreadsheet import. An import is queued, running, completed, or failed, so it starts as one field: status. Then the running view wants progress, the completed view wants a report, and the failed view wants a reason. Adding optional fields is the natural next step.

Read the flat recordTypeScript · the record this lesson starts from
state.ts
export type FlatImport = {
	status: 'queued' | 'running' | 'completed' | 'failed';
	processed?: number;
	total?: number;
	report?: { imported: number };
	reason?: string;
};

// This is accepted: status and payload are independent.
export const incomplete: FlatImport = { status: 'completed' };

export function describeFlat(record: FlatImport): string {
	switch (record.status) {
		case 'queued':
			return 'Waiting to start';
		case 'running':
			// Nothing ties the counts to running, so this branch checks for them again.
			return record.processed !== undefined && record.total !== undefined
				? `Imported ${record.processed} of ${record.total}`
				: 'Importing';
		case 'completed':
			// A completed record may have no report, so the reader invents something to say.
			return record.report ? `Finished: ${record.report.imported} imported` : 'Finished';
		case 'failed':
			return `Failed: ${record.reason ?? 'unknown reason'}`;
	}
}

Go’s version uses pointer fields for the same optional data. Both languages meet again at the cases in section 02.

Now look at what the record allows. { status: 'completed' } is a valid value with no report in it. So is a running import with a reason and no counts. Every reader checks each field again, and when the report is missing it has to invent something to say. describeFlat says “Finished” and hopes nobody asks how many rows came in.

A discriminated union is a type made of named cases, where each case carries its own data. The shared field that names the case, here status, is the discriminant. Check it once, and the rest of that case’s data comes with it.

If you write UI code, you’ve met the flat version as three flags: isLoading, error, and data, all of which can be set at once. TanStack Query hands you the case version instead. Its docs describe the query result as a “discriminated union type” with status as the discriminator, and once you check for success, data is no longer undefined. Section 05 follows that into a live activity feed.

02 / See the shape

Name the cases. Give each one its data.

The basic form is the whole idea: four cases and their fields. In the wild reads them, one branch per case. At the call site builds one of each.

Both languages print the same status lines from the same shared cases. They make different promises about completeness, and the reading notes say where.

Four cases, each carrying the data its situation needs. Check which case a value is, and its fields come with it.

TypeScriptReading
state.ts
export type ImportState =
	| { status: 'queued' }
	| { status: 'running'; processed: number; total: number }
	| { status: 'completed'; report: { imported: number } }
	| { status: 'failed'; reason: string };
GoAlongside
state.go
type ImportState interface{ importState() }

type Queued struct{}
type Running struct{ Processed, Total uint32 }
type Report struct{ Imported uint32 }
type Completed struct{ Report Report }
type Failed struct{ Reason string }

func (Queued) importState()    {}
func (Running) importState()   {}
func (Completed) importState() {}
func (Failed) importState()    {}
Reading the TypeScriptNarrowing, never, and erased types

switch (state.status) narrows state. Inside case 'completed', TypeScript knows which case this is, so state.report is available with no ?. and no check.

assertNever takes a parameter of type never. After four branches nothing is left, so the call type-checks. Add a case without a branch, and the leftover case isn’t assignable to never. That’s the compile error you’ll see in section 03.

A switch isn’t the only reader. statusLabels ends in satisfies Record<ImportState['status'], string>. The TypeScript 4.9 release notes describe satisfies as a way to “validate that the type of an expression matches some type, without changing the resulting type of that expression.” Add a status to the union, and the table stops compiling until it has a label.

Types are erased when the code runs. A value that arrives as JSON hasn’t been checked against ImportState just because a variable is annotated with it. See the handbook on discriminated unions.

Reading the GoAn interface, a type switch, and zero values

Go has no union type. Each case is its own struct, and the ImportState interface groups them through a small marker method. A type switch picks the branch, and each branch receives the concrete struct with its fields.

The interface isn’t a closed set. Any type in the package can add the marker method, so the compiler can’t know every case is handled. That’s why describe ends in a default that returns an error, and why the tests check an unhandled Paused.

Struct fields have zero values. Completed{} compiles with a report of zero, and Failed{} with an empty reason. Whether those mean anything is a rule for your domain, not the type.

03 / Follow the value

Watch one value fit the record, then fail the cases.

Five steps, each running the TypeScript you just read. The compiler messages are real: the lesson’s tests compile the source and check that they still match. Before each step, guess which column accepts the value.

In Try it, build a value yourself. Pick a case, choose its fields, add the paused case, and swap how describe ends.

Discriminated unions

One value, two models.

VALUE{ status: 'completed' }

Optional fieldsFlatImport

Value { status: 'completed' }. Flat record accepts it; describeFlat returns “Finished”.

01/ 05
Check a completed value against the flat record

Optional fields accept it.

{ status: ‘completed’ } fits the flat record. There’s no report, so the reader says “Finished” and moves on.

Reduced motion: choose a scene to see its completed state.

Read this scene

{ status: ‘completed’ } fits the flat record. There’s no report, so the reader says “Finished” and moves on.

Value { status: 'completed' }. Flat record accepts it; describeFlat returns “Finished”.

Watch restarts when you return. Step through keeps your selected step. Try it starts from the first value each time you open it.

What the cases buy you

Now put names on what you just watched. These are the words you’ll hear in a design review, and each one points at something on this page.

Illegal states don’t compile
{ status: 'completed' } without a report is a type error, with the message you saw in step 2. The flat record accepted it without a word.
Each branch knows its data
Inside case 'completed', state.report is there. No ?., no fallback text.
A new case finds its readers
Adding paused made describe stop compiling until it had a branch. So did statusLabels, until it had a label. Every exhaustive reader and checked table gets the same kind of error.
Fewer defensive checks
describe has no “Importing” or “unknown reason” strings. describeFlat needs three of them.
The type lists the situations
The cases are the checklist a designer, a test, and a new teammate all work from: every situation the status line has to show.

None of that is free, and step 5 already showed a limit. Section 08 covers what the cases can’t promise.

04 / Try a decision

A “just in case” default hides the next case.

Someone tidies describe so an unexpected status can never throw: return assertNever(state) becomes return ''. Every test still passes. A month later, imports can pause.

Paused ships. What does a paused import show?

describe now ends in default: return '' instead of return assertNever(state). Every other branch is unchanged.

05 / Give it a real job

Produce a case in one place. Read it everywhere else.

In the real app, the import job runner is the only code that creates an import state. It moves from queued to running as rows arrive, and to completed or failed when the file ends. The API sends the current state as JSON with its status field. The status line, the email summary, and the admin table each read one case.

Job runner

Produces the next case

Only it decides when running becomes completed.

API boundary

Encodes and parses

JSON out, checked cases back in.

Views

Read one case each

Status line, email summary, admin table.

The boundary is where the type earns its keep. The browser receives status as a string. Parse it into a case before any view sees it, or the promise in ImportState is only a comment. Parse, don’t validate covers that step.

The example leaves out the runner itself, the transitions between cases, and parsing imports from JSON. Every reader here receives a value that is already a checked case.

Build UIs?Every loading, error, and success you render is a set of cases, and one day a live feed makes you handle one you haven’t shipped yet.

Where it already is in your components

Every component that fetches something renders three situations: still loading, failed, loaded. As three flags, nothing stops isLoading and error from both being true, so the component checks them in a careful order and hopes. As cases, loading has nothing, error has a message, and success has the profile. The branch you’re in tells you what you can render.

That’s why TanStack Query can narrow data after isSuccess. Svelte templates narrow the same way: after {#if load.status === 'loading'} and an {:else if} for errors, only success is left in {:else}, and svelte-check knows load.profile exists there.

When you have to own it

Now it’s a live activity feed. Events arrive over a stream, and each type carries different fields: a comment has an author and text, a deploy has a service, a version, and whether it worked. The server team ships a new event type on Tuesday. Your app updates on Thursday.

So the cases start at the boundary. parseEvent turns each message into exactly one known case, or into an unknown case the parser creates on purpose. Rendering stays exhaustive: every case has a branch, and the last line only type-checks when nothing is left. Unknown is a case you handle, not a catch-all that swallows the next one.

That’s the whole lesson in one component: data that belongs to its case, a reader the compiler checks for completeness, and a boundary that has to earn the type.

feed.ts
// Activity events arrive from the server as JSON. The server can ship a new
// event type before this client knows about it.
export type FeedEvent =
	| { type: 'comment'; id: string; author: string; text: string }
	| { type: 'mention'; id: string; author: string; where: string }
	| { type: 'deploy'; id: string; service: string; version: string; ok: boolean }
	| { type: 'unknown'; id: string; received: string };

const isText = (value: unknown): value is string => typeof value === 'string';

// Parse at the boundary: every message becomes exactly one known case, an explicit
// unknown case, or nothing at all when it has no id to key a row by.
export function parseEvent(raw: unknown): FeedEvent | null {
	if (typeof raw !== 'object' || raw === null) return null;
	const event = raw as Record<string, unknown>;
	if (!isText(event.id)) return null;
	const id = event.id;
	switch (event.type) {
		case 'comment':
			if (isText(event.author) && isText(event.text)) {
				return { type: 'comment', id, author: event.author, text: event.text };
			}
			break;
		case 'mention':
			if (isText(event.author) && isText(event.where)) {
				return { type: 'mention', id, author: event.author, where: event.where };
			}
			break;
		case 'deploy':
			if (isText(event.service) && isText(event.version) && typeof event.ok === 'boolean') {
				return { type: 'deploy', id, service: event.service, version: event.version, ok: event.ok };
			}
			break;
	}
	// A type this client doesn't know yet, or a known type missing its fields.
	return { type: 'unknown', id, received: isText(event.type) ? event.type : 'no type' };
}

// Only callable once every case above has been handled: the argument must be never.
export function unhandled(event: never): never {
	throw new Error(`Unhandled feed event: ${JSON.stringify(event)}`);
}

A profile card that is loading, failed, or loaded. Each case carries what its branch renders. React switches on status; Svelte’s if-blocks narrow the same way.

ReactAlready in your code
ProfileCard.tsx
import { useEffect, useState } from 'react';

type Profile = { name: string; plan: string };

// Three flags can say "loading, failed, and here's the data" all at once:
// type Load = { isLoading: boolean; error?: string; profile?: Profile };

// One status. Each case carries exactly what its branch renders.
type Load =
	| { status: 'loading' }
	| { status: 'error'; message: string }
	| { status: 'success'; profile: Profile };

export function ProfileCard({ userId }: { userId: string }) {
	const [load, setLoad] = useState<Load>({ status: 'loading' });

	useEffect(() => {
		let current = true;
		setLoad({ status: 'loading' });
		fetch(`/api/users/${userId}`)
			.then((response) =>
				response.ok ? response.json() : Promise.reject(new Error(`HTTP ${response.status}`))
			)
			.then((profile: Profile) => {
				if (current) setLoad({ status: 'success', profile });
			})
			.catch((error: Error) => {
				if (current) setLoad({ status: 'error', message: error.message });
			});
		return () => {
			current = false;
		};
	}, [userId]);

	switch (load.status) {
		case 'loading':
			return <p>Loading…</p>;
		case 'error':
			return <p role="alert">Couldn’t load the profile: {load.message}</p>;
		case 'success':
			// Narrowed: load.profile exists here, and only here.
			return (
				<h2>
					{load.profile.name} · {load.profile.plan}
				</h2>
			);
	}
}

06 / Recognize it elsewhere

Anywhere one field decides which other fields exist.

You have probably written or read all of these without calling them unions.

Familiar code shaped as cases with their own data
Where you’ve seen itThe shapeWhat the case decides
A reducer action{ type: 'added', todo }The data that particular change needs.
A webhook event{ type: 'invoice.paid', data }Which fields data contains.
A result value{ ok: false, error }A value on success, an error on failure, never both.
A syntax tree*ast.CallExpr, *ast.IdentArguments for a call, a name for an identifier.

A shared field isn’t enough on its own. It’s a union when the other fields depend on it.

07 / Already in your toolbox

Your tools already hand you cases.

Three places to look. For each one, find the discriminant and what it unlocks.

TypeScript · discriminated unions

The handbook’s narrowing chapter: check a literal field, and TypeScript narrows to the matching case. Its exhaustiveness section uses the same never check as describe.

Read the handbook section ↗

TanStack Query · status

A query is pending, error, or success, and checking isSuccess narrows data. The docs describe the result as a discriminated union in so many words.

Open the type-narrowing docs ↗

Go · go/ast

The standard library’s syntax tree is a Node interface with a struct per construct, read with type switches. It’s the Go shape from section 02 at the scale of a compiler.

Look at ast.Inspect ↗
A useful counterexample: status and fetchStatusNot everything that varies is a case

TanStack Query keeps two fields. status says whether there’s data: pending, error, or success. fetchStatus says whether the query function is running: fetching, paused, or idle. A query can be success and fetching at once, during a background refetch.

Folding both into one union would need a case for every combination, or would hide the data you already have while it refreshes. When two things vary independently, they’re two properties. See TanStack Query on query status.

08 / The parts to watch

Cases hold the shape. Other rules still need a home.

The type can promise which data a case has. It can’t promise everything else.

JSON doesn’t check itself

An API response typed as ImportState is only a claim until something parses it. A server bug, an older client, or a status you’ve never seen all arrive as strings.

Parse at the boundary, and give the unexpected an explicit case, like the feed’s unknown.

A catch-all default hides the next case

default: return '' makes the compiler stop asking. Keep assertNever in readers that should handle every case, and use an explicit case, not a default, for input you expect to be unfamiliar.

A lookup table needs the same check

A plain object of labels, or a Partial<Record<…>>, lets a new status fall through to undefined. Checking the table against Record<Status, string> makes a missing key a compile error, like a missing branch.

Go’s interface isn’t a closed set

New structs can implement the marker, embedding can promote it into a wrapper, and a pointer is a different dynamic type from the value. In Go, completeness comes from the default’s error and from tests that feed it the cases you didn’t plan for.

A linter can add the check the compiler doesn’t. go-check-sumtype reads a //sumtype:decl comment on a sealed interface and reports a type switch that “either lacks a default clause or does not account for all possible variants.” A default counts as exhaustive unless you pass -default-signifies-exhaustive=false, so this lesson’s reader passes as written.

The shape can’t hold every rule

A running import with 9 processed of 3 total still fits the type. Nothing in the type stops a completed import from going back to queued, either. Arithmetic needs a check or a value object; transitions need the code that produces the next case, as in a state machine.

Independent properties aren’t cases

A pinned import, or a refresh over data you already have, can happen in more than one case. Keep it as its own field. A new case for every combination multiplies quickly.

09 / Make the call

What would you have to change tomorrow?

Give both designs a plausible change and follow the work it creates.

How a change affects optional fields and cases
The changeOptional fieldsCases
The completed view needs the reportCheck report again and decide what to show without it.Read state.report in the completed branch.
Imports can now pauseNothing points at the readers that need a paused branch.TypeScript flags every exhaustive reader and every checked table. Go’s default returns an error until it’s handled.
A form draft is half filled inFits. The draft really is incomplete.Forces a case too early. Parse into a case when the form is submitted.
Results refresh in the backgroundA second flag next to the data.Also a separate property. Refreshing isn’t a new case.

Reach for cases when different situations need different data, and a reader should never have to guess which fields exist. The completed import without a report is the moment.

Keep optional fields when the value is genuinely incomplete, or the fields vary independently. A draft and a background refresh are both fine as they are.

The question I’d leave beside the code is: once I know the case, what data can I rely on?

10 / Take the idea with you

Explain the status line without saying “discriminated union.”

“The status says which situation the import is in, and each situation carries exactly the data its view needs.” In a review, the words are make illegal states unrepresentable for what the cases rule out, and exhaustiveness checking for making the compiler find every reader and table a new case needs.

Before moving on, jot down which value the flat record allowed and the cases didn’t, why assertNever beats return '', and one component of yours that juggles isLoading, error, and data.

Connections to follow nextRelated lessons
  • Parse, don’t validate turns incoming JSON into a checked case, so the type means something at run time.
  • Kinds and sentinels asks the same question of errors: how does a caller recognize which failure this is?
  • State machine decides which case may follow which. This lesson only shapes each case.
  • Visitor adds behavior over a fixed set of cases, and shows what adding a case costs there.
  • Value objects give a field its own rules, like processed never exceeding total.
  • Making illegal states unrepresentable takes the first review word beyond cases: flags that contradict, counts that drift, and an index standing in for an id.

Take the cases into your editor. Add a canceled case that records who canceled it, and let the compiler show you every reader that needs a branch.

Back to Concepts & practices →