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
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.
export type ImportState =
| { status: 'queued' }
| { status: 'running'; processed: number; total: number }
| { status: 'completed'; report: { imported: number } }
| { status: 'failed'; reason: string }; 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.
One value, two models.
VALUE{ status: 'completed' }
FlatImportValue { status: 'completed' }. Flat record accepts it; describeFlat returns “Finished”.
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.reportis there. No?., no fallback text. - A new case finds its readers
- Adding
pausedmadedescribestop compiling until it had a branch. So didstatusLabels, until it had a label. Every exhaustive reader and checked table gets the same kind of error. - Fewer defensive checks
describehas no “Importing” or “unknown reason” strings.describeFlatneeds 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.
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.
Produces the next case
Only it decides when running becomes completed.
Encodes and parses
JSON out, checked cases back in.
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.
// 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.
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.
| Where you’ve seen it | The shape | What 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.Ident | Arguments 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.
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.
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.
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.
| The change | Optional fields | Cases |
|---|---|---|
| The completed view needs the report | Check report again and decide what to show without it. | Read state.report in the completed branch. |
| Imports can now pause | Nothing 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 in | Fits. The draft really is incomplete. | Forces a case too early. Parse into a case when the form is submitted. |
| Results refresh in the background | A 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.