Skip to content

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

  1. 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(", "));
  1. 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:

days: 3, gaps: 1, total: 39.0 kWh
2024-06-01 21.4, 2024-06-02 17.6, 2024-06-03 0.0
  1. Add a fourth variant — | { kind: "derived"; date: string; kwh: number; method: string } — and compile again: effectiveKwh no longer returns a value on every path, so the compiler reports TS2366. Removing confidence from the estimated entry gives TS2345 instead: 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 kind field makes it a discriminated union, and a switch over it specifies the domain.
  • Model states as a union of objects: impossible states stop existing instead of being discouraged.
  • A & B requires everything at once; a property the members disagree about collapses to never.
  • A const object plus keyof typeof replaces enum, and a discriminated result tells a caller why something failed where T | undefined cannot.

Check yourself

  1. Why does value.toFixed(1) fail when value is typed string | number, and what would you write?
  2. Which field must stay identical across the members of a discriminated union, and why?
  3. What value can satisfy both { kind: "measured" } and { kind: "estimated" }?
  4. A function returns number[] | undefined. What did the caller lose, and what would you change?

Next

Lesson 8 — Narrowing and Type Guards