"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.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:
"active", not string)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}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}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}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));Discriminated unions and pattern matching are among the most powerful patterns in TypeScript:
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."