07 — Unions, Intersections and Discriminated Unions
Level: Beginner · Time: ~30 min · Prerequisites: Lesson 6 — Arrays, Tuples and Objects
Why this matters
Real data is rarely one shape. A day of production is a measurement, an estimate or a gap; a screen is idle, loading, loaded or failed; an API answer is a payload or an error. Code that models those possibilities as booleans and nullable fields can represent states that must never exist — loading and failed, a payload and no response — and a union states the possibilities out loud instead.
Unions, and intersections, in one picture
A union says "one of these": only what every member has in common is available until you narrow the value (see Lesson 8). An intersection says "all of these", and composes shapes without a class tree.
type YieldSource = "pvgis" | "nasa-power";
function format(value: string | number): string {
return value.toFixed(1);
}
// ❌ TS2339: Property 'toFixed' does not exist on type 'string | number'
function sourceLabel(source: YieldSource): string {
return `source: ${source.toUpperCase()}`; // every member is a string
}
console.log(sourceLabel("pvgis"));
sourceLabel("pvgis2") is rejected with TS2345: a literal union is a closed set of values. The
other operator composes types instead of choosing between them:
type DailyYield = { date: string; kwh: number };
type Fetched = { source: YieldSource; fetchedAt: string };
type StoredYield = DailyYield & Fetched; // every property of both, all required
const stored: StoredYield = { date: "2024-06-01", kwh: 21.4, source: "pvgis", fetchedAt: "2024-06-02T06:00:00Z" };
console.log(stored.kwh);
An intersection is no merge that hides conflicts: a property the members disagree about becomes
never, so the type can never be satisfied. Lesson 10 compares it with interface X extends Y.
Discriminated unions: one literal field that says which shape you have
Add a shared literal property — kind, type or status — and the compiler can follow it. This is
the pattern to reach for whenever an object has variants.
type PvReading =
| { kind: "measured"; date: string; kwh: number }
| { kind: "estimated"; date: string; kwh: number; confidence: number }
| { kind: "missing"; date: string; reason: string };
function effectiveKwh(reading: PvReading): number {
switch (reading.kind) {
case "measured": return reading.kwh;
case "estimated": return reading.kwh * reading.confidence;
case "missing": return 0;
}
}
console.log(effectiveKwh({ kind: "estimated", date: "2024-06-02", kwh: 22, confidence: 0.8 }));
Inside each branch, reading is exactly the matching member: confidence exists only in the
estimated branch, and nowhere else. The switch is the clearest domain specification you can write.
State as a union, not as a set of booleans
Three booleans have eight combinations and only three of them mean anything.
type LoadStatus =
| { status: "idle" }
| { status: "loading" }
| { status: "loaded"; days: number[] }
| { status: "error"; message: string };
function describe(state: LoadStatus): string {
switch (state.status) {
case "idle": return "not started";
case "loading": return "loading…";
case "loaded": return `${state.days.length} days`;
case "error": return `failed: ${state.message}`;
}
}
console.log(describe({ status: "loaded", days: [21.4, 18.9] }));
The boolean version accepts { isLoading: true, isError: true, days: null } and leaves the interface
guessing. Here, days exists only in the loaded state and message only in the error state.
Literal unions instead of enums
enum is the one TypeScript feature that emits runtime code. A const object plus keyof typeof
gives the same closed set of values as plain data.
const YieldSource = { Pvgis: "pvgis", NasaPower: "nasa-power" } as const;
type YieldSource = (typeof YieldSource)[keyof typeof YieldSource]; // "pvgis" | "nasa-power"
function label(source: YieldSource): string {
return source === YieldSource.Pvgis ? "PVGIS" : "NASA POWER";
}
console.log(label("pvgis")); // a literal is accepted directly
enum LegacySource { Pvgis = "pvgis", NasaPower = "nasa-power" }
console.log(LegacySource.Pvgis); // a real object is emitted into the JavaScript
Because the object is real, an enum cannot be removed by a type-stripping runtime and is rejected by
erasableSyntaxOnly; it is also checked nominally, so a string from an API needs a cast. A literal
union survives JSON.stringify and JSON.parse unchanged, which is what you want at a boundary.
Optional is not nullable, and a result beats a bare union
{ a?: T } and { a: T | undefined } look alike and mean different things once
exactOptionalPropertyTypes is on.
type Optional = { source?: string }; // the property may be absent
type Nullable = { source: string | undefined }; // it must be present, may be undefined
const empty: Optional = {};
const rejected: Optional = { source: undefined }; // ❌ only when exactOptionalPropertyTypes is true
type FetchResult = { ok: true; days: number[] } | { ok: false; reason: "network" | "rate-limited" | "parse" };
function unwrap(result: FetchResult): number[] | undefined {
return result.ok ? result.days : undefined; // the caller never learns the reason
}
console.log(unwrap({ ok: false, reason: "network" }), empty, rejected);
unwrap tells its caller that something failed, not why. Returning FetchResult and letting the
caller switch on ok keeps the reason next to the code that knows it — prefer the richer result.
Common mistakes
| Mistake | What happens | The fix |
|---|---|---|
| Reading a property before narrowing | TS2339 on a member that exists in only one branch |
Narrow first with kind, typeof or in (Lesson 8) |
| Two booleans for three states | A fourth, impossible state type-checks and ships | Model the states as one discriminated union |
| Forgetting the shared literal field | Each variant becomes an unrelated type and the switch narrows nothing | Give every member the same literal property |
Choosing ? when the key must exist |
Absent and "present but undefined" become indistinguishable | Use T \| undefined and exactOptionalPropertyTypes |
Returning T \| undefined from a fallible function |
Callers cannot tell a network failure from an empty list | Return a discriminated result carrying a reason |
Using enum for a set of strings |
Extra runtime code, no serialisation safety, rejected by erasableSyntaxOnly |
A const object plus keyof typeof |
Try it
- In the project from Lesson 2, create
src/unions.ts:
type PvReading =
| { kind: "measured"; date: string; kwh: number }
| { kind: "estimated"; date: string; kwh: number; confidence: number }
| { kind: "missing"; date: string; reason: string };
const week: PvReading[] = [
{ kind: "measured", date: "2024-06-01", kwh: 21.4 },
{ kind: "estimated", date: "2024-06-02", kwh: 22, confidence: 0.8 },
{ kind: "missing", date: "2024-06-03", reason: "inverter offline" },
];
function effectiveKwh(reading: PvReading): number {
switch (reading.kind) {
case "measured": return reading.kwh;
case "estimated": return reading.kwh * reading.confidence;
case "missing": return 0;
}
}
const total = week.reduce((sum, r) => sum + effectiveKwh(r), 0);
const gaps = week.filter((r) => r.kind === "missing").length;
console.log(`days: ${week.length}, gaps: ${gaps}, total: ${total.toFixed(1)} kWh`);
console.log(week.map((r) => `${r.date} ${effectiveKwh(r).toFixed(1)}`).join(", "));
- Compile and run it:
npx tsc --target ES2023 --lib ES2023 --module NodeNext --moduleResolution NodeNext --outDir dist src/unions.ts
node dist/unions.js
Expected output:
- Add a fourth variant —
| { kind: "derived"; date: string; kwh: number; method: string }— and compile again:effectiveKwhno longer returns a value on every path, so the compiler reportsTS2366. Removingconfidencefrom the estimated entry givesTS2345instead: the variants are distinct objects, not one shape with optional fields.
Key takeaways
- A union is a closed set: only shared behaviour is available until the value is narrowed.
- A literal
kindfield makes it a discriminated union, and aswitchover it specifies the domain. - Model states as a union of objects: impossible states stop existing instead of being discouraged.
A & Brequires everything at once; a property the members disagree about collapses tonever.- A
constobject pluskeyof typeofreplacesenum, and a discriminated result tells a caller why something failed whereT | undefinedcannot.
Check yourself
- Why does
value.toFixed(1)fail whenvalueis typedstring | number, and what would you write? - Which field must stay identical across the members of a discriminated union, and why?
- What value can satisfy both
{ kind: "measured" }and{ kind: "estimated" }? - A function returns
number[] | undefined. What did the caller lose, and what would you change?