We use cookies to enhance your experience on the site
CodeWorlds

Discriminated Unions and Pattern Matching

"In the Jurassic Park control center, every situation requires a different response," explains Claire Dearing, the park's operations director. "A dinosaur escape, a power failure, a visitor injury - each incident has its own protocol. We can't mix one up with another, because the consequences would be catastrophic. The system must identify the event type on its own and direct us to the correct procedure."

Discriminated unions are a powerful TypeScript pattern in which each variant of a union has a common field - a discriminator (tag) - that uniquely identifies the type. Combined with pattern matching based on

switch
, they create an elegant, safe, and extensible system for handling different states and events.

Discriminated unions basics

A discriminated union is a union of types where each type has a field with the same name (tag) but with a different literal value. TypeScript can narrow the type based on the value of this field.

1// The "status" field is the discriminator
2interface ActiveDinosaur {
3  status: "active";
4  name: string;
5  enclosureId: string;
6  lastFed: Date;
7}
8
9interface SleepingDinosaur {
10  status: "sleeping";
11  name: string;
12  enclosureId: string;
13  sleepStarted: Date;
14  estimatedWakeUp: Date;
15}
16
17interface SickDinosaur {
18  status: "sick";
19  name: string;
20  enclosureId: string;
21  diagnosis: string;
22  veterinarianId: string;
23}
24
25type DinosaurState = ActiveDinosaur | SleepingDinosaur | SickDinosaur;
26
27// TypeScript narrows the type after checking the status field
28function getDinosaurReport(dino: DinosaurState): string {
29  switch (dino.status) {
30    case "active":
31      // TypeScript knows this is ActiveDinosaur - we have access to lastFed
32      return `${dino.name}: Active, last feeding: ${dino.lastFed.toLocaleString()}`;
33    case "sleeping":
34      // TypeScript knows this is SleepingDinosaur - we have access to estimatedWakeUp
35      return `${dino.name}: Sleeping, estimated wake-up: ${dino.estimatedWakeUp.toLocaleString()}`;
36    case "sick":
37      // TypeScript knows this is SickDinosaur - we have access to diagnosis
38      return `${dino.name}: Sick - ${dino.diagnosis}, veterinarian: ${dino.veterinarianId}`;
39  }
40}

Key aspects of discriminated unions:

  1. The common field (tag/discriminator) must have a literal type (e.g.,
    "active"
    , not
    string
    )
  2. Each variant has a unique tag value
  3. TypeScript automatically narrows the type after checking the tag value

Pattern matching with switch

The

switch
construct is a natural tool for pattern matching in TypeScript. Each
case
corresponds to one variant of the discriminated union.

1// Jurassic Park event system
2interface FeedingEvent {
3  type: "feeding";
4  dinosaurId: string;
5  food: string;
6  amountKg: number;
7  timestamp: Date;
8}
9
10interface EscapeEvent {
11  type: "escape";
12  dinosaurId: string;
13  enclosureId: string;
14  breachType: "fence" | "gate" | "underground";
15  timestamp: Date;
16}
17
18interface MaintenanceEvent {
19  type: "maintenance";
20  systemId: string;
21  description: string;
22  priority: "low" | "medium" | "high" | "critical";
23  timestamp: Date;
24}
25
26interface VisitorEvent {
27  type: "visitor";
28  visitorCount: number;
29  zone: string;
30  eventKind: "entry" | "exit" | "emergency";
31  timestamp: Date;
32}
33
34type ParkEvent = FeedingEvent | EscapeEvent | MaintenanceEvent | VisitorEvent;
35
36// Pattern matching - handling each event type
37function processParkEvent(event: ParkEvent): { action: string; urgency: number } {
38  switch (event.type) {
39    case "feeding":
40      return {
41        action: `Feeding ${event.dinosaurId}: ${event.amountKg}kg ${event.food}`,
42        urgency: 1
43      };
44
45    case "escape":
46      return {
47        action: `ALARM! ${event.dinosaurId} escaped from ${event.enclosureId} (type: ${event.breachType})`,
48        urgency: 10
49      };
50
51    case "maintenance":
52      const urgencyMap = { low: 2, medium: 4, high: 7, critical: 9 };
53      return {
54        action: `Maintenance ${event.systemId}: ${event.description}`,
55        urgency: urgencyMap[event.priority]
56      };
57
58    case "visitor":
59      const visitorUrgency = event.eventKind === "emergency" ? 8 : 1;
60      return {
61        action: `Visitors (${event.visitorCount}) in ${event.zone}: ${event.eventKind}`,
62        urgency: visitorUrgency
63      };
64  }
65}

Exhaustive handling - guaranteeing all variants are handled

One of the greatest advantages of discriminated unions is the possibility of exhaustive handling - the compiler can check whether you've handled ALL variants. If you add a new type to the union, TypeScript will immediately point out all places that need updating.

1// Helper for exhaustive check
2function assertNever(value: never, message?: string): never {
3  throw new Error(message || `Unhandled variant: ${JSON.stringify(value)}`);
4}
5
6// If we add a new event type to ParkEvent and forget to handle it,
7// TypeScript will report an error in the default clause
8function processEventStrict(event: ParkEvent): string {
9  switch (event.type) {
10    case "feeding":
11      return "Feeding";
12    case "escape":
13      return "Escape";
14    case "maintenance":
15      return "Maintenance";
16    case "visitor":
17      return "Visitors";
18    default:
19      // If a case were missing, TypeScript would report an error:
20      // "Type 'XXX' is not assignable to type 'never'"
21      return assertNever(event);
22  }
23}

State machines with discriminated unions

Discriminated unions are perfectly suited for modeling state machines, where an object can be in one of several states, and transitions between states are strictly controlled.

1// Dinosaur enclosure states as a state machine
2interface EnclosureNormal {
3  state: "normall";
4  dinosaurCount: number;
5  securityLevel: number;
6}
7
8interface EnclosureLockdown {
9  state: "lockdown";
10  dinosaurCount: number;
11  securityLevel: number;
12  reason: string;
13  lockdownSince: Date;
14}
15
16interface EnclosureBreach {
17  state: "breach";
18  dinosaurCount: number;
19  escapedCount: number;
20  breachLocation: string;
21  responseTeamDispatched: boolean;
22}
23
24interface EnclosureMaintenance {
25  state: "maintenance";
26  dinosaurCount: 0; // Enclosure must be empty during maintenance
27  scheduledEnd: Date;
28  maintenanceType: string;
29}
30
31type EnclosureState = EnclosureNormal | EnclosureLockdown | EnclosureBreach | EnclosureMaintenance;
32
33// Allowed transitions between states
34function transitionEnclosure(
35  current: EnclosureState,
36  action: "lockdown" | "breach" | "repair" | "resolve"
37): EnclosureState {
38  switch (current.state) {
39    case "normall":
40      if (action === "lockdown") {
41        return {
42          state: "lockdown",
43          dinosaurCount: current.dinosaurCount,
44          securityLevel: current.securityLevel + 5,
45          reason: "Security procedure",
46          lockdownSince: new Date()
47        };
48      }
49      if (action === "repair") {
50        if (current.dinosaurCount > 0) {
51          throw new Error("Cannot start maintenance - enclosure occupied!");
52        }
53        return {
54          state: "maintenance",
55          dinosaurCount: 0,
56          scheduledEnd: new Date(Date.now() + 86400000),
57          maintenanceType: "Standard inspection"
58        };
59      }
60      return current;
61
62    case "lockdown":
63      if (action === "breach") {
64        return {
65          state: "breach",
66          dinosaurCount: current.dinosaurCount - 1,
67          escapedCount: 1,
68          breachLocation: "Unknown sector",
69          responseTeamDispatched: false
70        };
71      }
72      if (action === "resolve") {
73        return {
74          state: "normall",
75          dinosaurCount: current.dinosaurCount,
76          securityLevel: current.securityLevel
77        };
78      }
79      return current;
80
81    case "breach":
82      if (action === "resolve") {
83        return {
84          state: "lockdown",
85          dinosaurCount: current.dinosaurCount + current.escapedCount,
86          securityLevel: 10,
87          reason: "Post-breach lockdown",
88          lockdownSince: new Date()
89        };
90      }
91      return current;
92
93    case "maintenance":
94      if (action === "resolve") {
95        return {
96          state: "normall",
97          dinosaurCount: 0,
98          securityLevel: 5
99        };
100      }
101      return current;
102  }
103}
104
105// State machine test
106let enclosure: EnclosureState = {
107  state: "normall",
108  dinosaurCount: 3,
109  securityLevel: 5
110};
111
112console.log("Initial state:", enclosure.state);
113enclosure = transitionEnclosure(enclosure, "lockdown");
114console.log("After lockdown:", enclosure.state);
115enclosure = transitionEnclosure(enclosure, "breach");
116console.log("After breach:", enclosure.state);
117if (enclosure.state === "breach") {
118  console.log("Escaped dinosaurs:", enclosure.escapedCount);
119}

API responses as discriminated unions

A common real-world application is modeling API responses using discriminated unions:

1// Generic API response type
2interface SuccessResponse<T> {
3  status: "success";
4  data: T;
5  timestamp: Date;
6}
7
8interface ErrorResponse {
9  status: "error";
10  errorCode: number;
11  message: string;
12  timestamp: Date;
13}
14
15interface LoadingResponse {
16  status: "loading";
17  progress: number; // 0-100
18}
19
20type ApiResponse<T> = SuccessResponse<T> | ErrorResponse | LoadingResponse;
21
22// Dinosaur API data type
23interface DinosaurApiData {
24  id: string;
25  name: string;
26  species: string;
27  weight: number;
28}
29
30// Response handling with full type safety
31function renderDinosaurData(response: ApiResponse<DinosaurApiData>): string {
32  switch (response.status) {
33    case "loading":
34      return `Loading... ${response.progress}%`;
35    case "error":
36      return `Error ${response.errorCode}: ${response.message}`;
37    case "success":
38      const { name, species, weight } = response.data;
39      return `${name} (${species}) - ${weight} kg`;
40  }
41}
42
43// Test responses
44const loading: ApiResponse<DinosaurApiData> = { status: "loading", progress: 45 };
45const success: ApiResponse<DinosaurApiData> = {
46  status: "success",
47  data: { id: "D-001", name: "Rexy", species: "T-Rex", weight: 8000 },
48  timestamp: new Date()
49};
50const error: ApiResponse<DinosaurApiData> = {
51  status: "error",
52  errorCode: 404,
53  message: "Dinosaur not found",
54  timestamp: new Date()
55};
56
57console.log(renderDinosaurData(loading));
58console.log(renderDinosaurData(success));
59console.log(renderDinosaurData(error));

Summary

Discriminated unions and pattern matching are among the most powerful patterns in TypeScript:

  1. Discriminated unions - each variant has a common field (tag) with a unique literal value
  2. Pattern matching with switch - elegant handling of all variants
  3. Exhaustive handling with never - the compiler ensures you've handled every variant
  4. State machines - modeling allowed transitions between states
  5. API responses - typing success/error/loading with full safety

As Claire Dearing says: "In Jurassic Park, every scenario has its protocol. With discriminated unions, TypeScript ensures that no scenario is overlooked. It's like an early warning system - it doesn't allow any threat to be missed."

Go to CodeWorlds