JavaScript and React course Β· Module 12: React and TypeScript

Discriminated Unions - Component Variants

5 min read
In this lesson5

A cosmic object card receives a planet one time, a star the next, an asteroid after that. You throw all fields into one type, mark them optional, and a month later nobody remembers whether a star can have moons. In space, different objects require different handling - a planet, a star, and an asteroid are different entities with different properties. Discriminated unions let TypeScript distinguish object variants based on a single field.

The Problem - Different Data Shapes

Imagine a component that displays different types of cosmic objects. Each type has different fields. The simplest solution looks innocent:

1// BAD - unclear which fields are available
2interface SpaceObject {
3  type: string;
4  name: string;
5  radius?: number;
6  temperature?: number;
7  composition?: string[];
8  orbitalPeriod?: number;
9}
10
11function SpaceInfo({ obj }: { obj: SpaceObject }) {
12  // You must check each field separately
13  if (obj.radius) { /* ... */ }
14  if (obj.temperature) { /* ... */ }
15  // Easy to make mistakes!
16}

This type lets you create a "planet" with a star's temperature and no radius - the compiler can't catch it, because everything is optional. On top of that, if (obj.radius) skips a planet with a radius of 0, because zero is falsy.

The Solution - Discriminated Unions

Instead of one interface with optional fields, we create separate interfaces connected by a common field (the discriminator). The key is that the discriminator has a literal type: not any string, but one specific string, for example kind: "planet". That string is how TypeScript recognizes the variant.

1// Each type has a common 'kind' field with a different value
2interface PlanetData {
3  kind: "planet";
4  name: string;
5  radius: number;
6  habitable: boolean;
7  moons: number;
8}
9
10interface StarData {
11  kind: "star";
12  name: string;
13  temperature: number;
14  luminosity: number;
15  spectralClass: string;
16}
17
18interface AsteroidData {
19  kind: "asteroid";
20  name: string;
21  composition: string[];
22  diameter: number;
23}

Now we join the three interfaces with | into one union type. A value of type SpaceObject is exactly one of the three variants, never a mix:

1// Union type - the object can be one of three types
2type SpaceObject = PlanetData | StarData | AsteroidData;

A planet without radius or a star with a moons field no longer compiles. All fields in the variants are required - the optionality is gone, because each variant describes only what it really has.

Type Narrowing

TypeScript automatically narrows the type based on the discriminator field. When you check obj.kind in a switch statement, inside each case the compiler knows which variant you're working with:

1function SpaceInfo({ obj }: { obj: SpaceObject }) {
2  // Common field - available for all variants
3  const header = <h2>{obj.name}</h2>;
4
5  // Switch on the discriminator
6  switch (obj.kind) {
7    case "planet":
8      // TypeScript KNOWS that obj is PlanetData
9      return (
10        <div>
11          {header}
12          <p>Radius: {obj.radius} km</p>
13          <p>Moons: {obj.moons}</p>
14          <p>{obj.habitable ? "Habitable" : "Uninhabitable"}</p>
15        </div>
16      );
17    case "star":
18      // TypeScript KNOWS that obj is StarData
19      return (
20        <div>
21          {header}
22          <p>Temperature: {obj.temperature}K</p>
23          <p>Class: {obj.spectralClass}</p>
24        </div>
25      );
26    case "asteroid":
27      // TypeScript KNOWS that obj is AsteroidData
28      return (
29        <div>
30          {header}
31          <p>Diameter: {obj.diameter} km</p>
32          <p>Composition: {obj.composition.join(", ")}</p>
33        </div>
34      );
35  }
36}

Before the switch, only the common name field is available. In the "star" branch, reading obj.moons is an error, because stars don't have moons. What did NOT change: in running code this is an ordinary switch on a string - all the magic happens in the compiler.

UI Component Variants

Discriminated unions are ideal for creating UI component variants. A success alert needs only a message, but an error alert must receive a retry action. The type can enforce that:

1// Alert variants
2type AlertProps =
3  | { variant: "success"; message: string }
4  | { variant: "error"; message: string; retryAction: () => void }
5  | { variant: "warning"; message: string; dismissable: boolean }
6  | { variant: "info"; message: string; link?: string };

This time the variants are written inline, without separate interfaces, and the discriminator is variant. The component takes the whole props without destructuring, because destructuring before checking the variant makes narrowing harder. We build CSS classes with a template string:

1function Alert(props: AlertProps) {
2  const baseClass = "alert";
3
4  switch (props.variant) {
5    case "success":
6      return <div className={`${baseClass} success`}>{props.message}</div>;
7    case "error":
8      return (
9        <div className={`${baseClass} error`}>
10          {props.message}
11          <button onClick={props.retryAction}>Retry</button>
12        </div>
13      );
14    case "warning":
15      return (
16        <div className={`${baseClass} warning`}>
17          {props.message}
18          {props.dismissable && <button>Dismiss</button>}
19        </div>
20      );
21    case "info":
22      return (
23        <div className={`${baseClass} info`}>
24          {props.message}
25          {props.link && <a href={props.link}>Learn more</a>}
26        </div>
27      );
28  }
29}

The call <Alert variant="error" message="Failure" /> without retryAction is rejected - the component won't let you build an error alert without a rescue button.

Exhaustive Checking - Never Miss a Variant

What if a year from now someone adds a "comet" variant and forgets to handle it? The never type helps: it means a value that cannot exist. When a switch handles every variant, the variable in the default branch has the type never. The assertNever function accepts only never, so every unhandled variant becomes a compile error:

1// Helper function - TypeScript will report an error if you don't handle all variants
2function assertNever(value: never): never {
3  throw new Error(`Unhandled variant: ${value}`);
4}
5
6function getLabel(obj: SpaceObject): string {
7  switch (obj.kind) {
8    case "planet": return "Planet";
9    case "star": return "Star";
10    case "asteroid": return "Asteroid";
11    default: return assertNever(obj); // error if you add a new type and don't handle it
12  }
13}

After adding CometData to the union, TypeScript points at the assertNever line before the code reaches the crew. The throw only fires if invalid data arrives from outside at runtime. My advice: add assertNever to every switch on a discriminator - it's a cheap insurance policy. In the next lesson a discriminated union will describe the actions in useReducer.

Code for this lesson: App.tsx
1import React, { useState } from 'react';
2
3// Discriminated union - different cosmic objects
4interface PlanetData {
5  kind: "planet";
6  name: string;
7  radius: number;
8  habitable: boolean;
9  moons: number;
10}
11
12interface StarData {
13  kind: "star";
14  name: string;
15  temperature: number;
16  luminosity: number;
17  spectralClass: "O" | "B" | "A" | "F" | "G" | "K" | "M";
18}
19
20interface AsteroidData {
21  kind: "asteroid";
22  name: string;
23  diameter: number;
24  composition: string[];
25  dangerLevel: 1 | 2 | 3 | 4 | 5;
26}
27
28type SpaceObject = PlanetData | StarData | AsteroidData;
29
30// Component with type narrowing
31function SpaceCard({ obj }: { obj: SpaceObject }) {
32  const getIcon = (): string => {
33    switch (obj.kind) {
34      case "planet": return "●";
35      case "star": return "*";
36      case "asteroid": return "β€’";
37    }
38  };
39
40  const renderDetails = (): React.ReactNode => {
41    switch (obj.kind) {
42      case "planet":
43        return (
44          <>
45            <p>Radius: {obj.radius} km</p>
46            <p>Moons: {obj.moons}</p>
47            <span className={"badge " + (obj.habitable ? "green" : "red")}>
48              {obj.habitable ? "Habitable" : "Uninhabitable"}
49            </span>
50          </>
51        );
52      case "star":
53        return (
54          <>
55            <p>Temperature: {obj.temperature}K</p>
56            <p>Luminosity: {obj.luminosity}x Sun</p>
57            <span className="badge blue">Class {obj.spectralClass}</span>
58          </>
59        );
60      case "asteroid":
61        return (
62          <>
63            <p>Diameter: {obj.diameter} km</p>
64            <p>Composition: {obj.composition.join(", ")}</p>
65            <span className="badge orange">
66              Danger: {"!".repeat(obj.dangerLevel)}
67            </span>
68          </>
69        );
70    }
71  };
72
73  return (
74    <div className={"space-card " + obj.kind}>
75      <div className="card-header">
76        <span className="icon">{getIcon()}</span>
77        <h3>{obj.name}</h3>
78        <span className="kind">{obj.kind}</span>
79      </div>
80      <div className="card-body">{renderDetails()}</div>
81    </div>
82  );
83}
84
85function App() {
86  const [objects] = useState<SpaceObject[]>([
87    { kind: "planet", name: "Kepler-442b", radius: 8200, habitable: true, moons: 2 },
88    { kind: "star", name: "Betelgeuse", temperature: 3500, luminosity: 126000, spectralClass: "M" },
89    { kind: "asteroid", name: "Apophis", diameter: 370, composition: ["iron", "nickel", "silicon"], dangerLevel: 4 },
90    { kind: "planet", name: "HD 209458 b", radius: 94000, habitable: false, moons: 0 },
91    { kind: "star", name: "Sirius", temperature: 9940, luminosity: 25, spectralClass: "A" },
92    { kind: "asteroid", name: "Ceres", diameter: 939, composition: ["ice", "rock", "minerals"], dangerLevel: 1 },
93  ]);
94
95  const [filter, setFilter] = useState<SpaceObject["kind"] | "all">("all");
96
97  const filtered = filter === "all"
98    ? objects
99    : objects.filter(obj => obj.kind === filter);
100
101  return (
102    <div className="app">
103      <h1>Cosmic Object Catalog</h1>
104      <div className="filters">
105        {(["all", "planet", "star", "asteroid"] as const).map(f => (
106          <button
107            key={f}
108            className={"filter-btn " + (filter === f ? "active" : "")}
109            onClick={() => setFilter(f)}
110          >
111            {f === "all" ? "All" : f === "planet" ? "Planets" : f === "star" ? "Stars" : "Asteroids"}
112          </button>
113        ))}
114      </div>
115      <div className="cards">
116        {filtered.map((obj, i) => <SpaceCard key={i} obj={obj} />)}
117      </div>
118    </div>
119  );
120}
121
122export default App;

Remember: the discriminator is the object's identification signal - read it, and TypeScript switches the sensors to the right variant by itself.

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. What type must the discriminator field (e.g. kind) be in a discriminated union for TypeScript to automatically narrow the type?

  2. 2. What is a discriminated union in TypeScript?

These are 2 of 3 questions for this lesson. Solve the rest in the game.

Hands-on tasks in the game

  • Vertical ordering

    Arrange the steps for creating and using a discriminated union in the correct order:

  • Code editor

    Discriminated union for component variants

  • Click in order

    Arrange the syntax of the assertNever function for exhaustive checking:

  • Horizontal ordering

    Arrange the syntax for defining a union type from three interfaces:

  • Code editor

    Generic discriminated union for asynchronous states

Useful articles