Low Maintenance Types in TypeScript

Seite 2: Discriminated Union Types

Inhaltsverzeichnis

The following example shows further capabilities of TypeScript. In an exemplary toy shop inventory software, a data model is defined by using types. ToyBase describes all properties that are required by each toy. Afterwards, intersection types are used to define different kinds of toys in detail.

type ToyBase = {
  name: string;
  price: number;
  quantity: number;
  minimumAge: number;
};

type BoardGame = ToyBase & {
  kind: "boardgame";
  players: number;
}

type Puzzle = ToyBase & {
  kind: "puzzle";
  pieces: number;
}

type Doll = ToyBase & {
  kind: "doll";
  material: "plastic" | "plush";
}

The use of intersection types is similar to the operation of extending interfaces. Both techniques narrow down the set of possible values by adding and requiring additional properties.

It is worth taking a closer look at the property kind. It is not a string as may be expected, but a literal string value. In TypeScript, each literal value can be a type. For example, the set of compatible values to the literal type puzzle is only puzzle, and nothing more. This might seem confusing at first, but has a huge effect on how TypeScript views a union type:

type Toy = BoardGame | Puzzle | Doll;

Toy describes all possible toys in the software. Using the kind property and setting it to literal values prohibits an intersection between the subsets. It is impossible that kind is both doll and puzzle at the same time. Therefore, values can be exactly one subtype. However, developers are able to discriminate between each type. This is how these types have received their name: discriminated union types.

Discriminated union types can be used to write functions that provide exhaustiveness checks. This means they make sure that all possible variants of a union type have been exhausted, as demonstrated in the printToys function:

function printToys(toy: Toy) {
  switch(toy.kind) {
    case "boardgame":
      // toy is BoardGame
      break;
    case "puzzle":
      // toy is Puzzle
      break;
    case "doll":
      // toy is Doll
      break;
    default: 
      assertNever(toy)
  }
}

function assertNever(obj: never) {
  throw Error("I can't work with this type")
}

The switch statement enables iterating over each possible subtype of toy. In each case, TypeScript knows exactly which subtype to handle because of the uniqueness of the kind property. It gets even better: In the default case, TypeScript expects all possibilities to have been exhausted, which means the type of toy would be never, a type that contains no values. It is reserved for cases that should never occur—just like the default case in this example. If the default case does appear, that poses a serious problem as the data does not comply with the previously defined types. It is OK to throw an error here.

Additional safeties may be implemented, so as not to overlook new cases. For example, the union type Toy may be extended to include video games:

type VideoGame = ToyBase & {
  kind: "videogame";
  system: "NES" | "SNES" | "Mega Drive" | 
  "There are no more consoles"; 
};

type Toy = BoardGame | Puzzle | Doll | VideoGame

TypeScript will throw an error at the exact position where one might have forgotten to check for all cases:

function printToys(toy: Toy) {
  switch(toy.kind) {
    case "boardgame":
      // toy is BoardGame
      break;
    case "puzzle":
      // toy is Puzzle
      break;
    case "doll":
      // toy is Doll
      break;
    default: 
      // Wait! toy is of type VideoGame, not never.
	// Calling assertNever is not valid
      assertNever(toy)
  }
}

That is handy!

A different part of the software needs a grouping of all toys. The properties of this type are equivalent to the values of the kind property in each toy.

type GroupedToys = {
  boardgame: Toy[];
  puzzle: Toy[];
  doll: Toy[];
};

GroupedToys may seem like an easy type but contains various possible pitfalls. For one thing, there are typos to consider. Nothing keeps developers from accidentally writing "boredGame” instead of "boardgame,” which has the potential to break the code at some point. And with JavaScript, typos can happen anywhere!

Additionally, TypeScript has no clue that there is a relationship between the properties of GroupedToys and the kind literal type. This may lead to situations that require circumventing TypeScript’s type safety by using explicit type assertions and overrides. In many cases, developers end up maintaining the code. The moment VideoGame is added, it becomes necessary to update GroupedToys—another potential source of errors.

Defining relationships between types avoids this issue. To do this, it must be ensured that all toy kinds are covered. For this, a union of string literal types can be created directly from Toy by using an index access type.

//equivalent to "boardgame" | "videogame" | "doll" | "puzzle"
type ToyKind = Toy["kind"] 

Since the kind property has a literal type, ToyKind results in a union type with exactly four possible values. These values serve as keys for other values. Properties in JavaScript objects can be strings, numbers, or symbols. String keys can be anything in JavaScript, whereas TypeScript requires formalizing a subset. With ToyKind, such a subset has been established. Using a mapped type, this set can act as a basis from which to create a new object type.

type GroupedToys = {
  [Kind in ToyKind]: Toy[]
}

This type is equivalent to the original, hand-written GroupedToys type, but with an important difference: It has a relationship to Toy. Once Toy receives an update, ToyKind is updated as well, and so is GroupedToys. Therefore, maintenance only needs to happen in one place: the model.

TypeScript allows developers to go even further with this. When generating new properties based on the union type ToyKind, the types associated with each property are a Toy array. Shouldn't more information be available, though? In fact, GroupedToys only makes sense if it can be ensured that each toy collected in boardgame is of the type BoardGame.

An exact mapping to valid types is possible. So far, the type alias Kind in the GroupedToys type has not been used to its full potential.

type GroupedToys = {
  [Kind in ToyKind]: Toy[]
}

Currently, it outputs boardgame, videogame, doll, and puzzle as new keys, but the same type can be used in different places. As a parameter for a custom generic helper type GetKind, for example. Quickly added to the code, GetKind is responsible for fetching exactly one subset of a discriminated union type based on the kind property.

type GroupedToys = {
  [Kind in ToyKind]: GetKind<Toy, Kind>[]
}

It is possible to implement GetKind based on conditional types. They allow checking whether a certain type T is a subtype of another type U. This means that each value that is compatible with T is compatible with U as well. If that is the case, a conditional type returns the type in the true branch. Otherwise, it returns the type in the false branch.

Extract is a helper type based on conditional types. It does a subtype check as shown below: If type T is a subtype of U, it returns T. Otherwise, it returns never.

type Extract<T, U> = T extends U ? T : never

For example, Extract can be used to extract Doll from Toy. This requires a generic type GetKind that takes two generic type parameters. One is the group to extract from, the other one is the kind to extract.

type GetKind<Group, Kind> = Extract<Group, { kind: Kind }>

type Dolls = GetKind<Toy, "doll">

Group, in this case Toy, is a union type. To TypeScript, a conditional type of a union is a union of conditional types. This means that a call like

type Dolls = GetKind<Toy, "doll">

which is replaced with

type Dolls = Extract<Toy, { kind: "doll" }>

expands to a union type like this:

type Dolls = BoardGame extends 
    { kind: "doll"} ? BoardGame : never |
	Doll extends { kind: "doll"} ? Doll : never |
	Puzzle extends { kind: "doll"} ? Puzzle : never |
	VideoGame extends { kind: "doll"} ? VideoGame : never

Now every part of the Toy union will be checked against a type { kind: "doll" }. Only one of the four types is an actual subtype of { kind: "doll" }, namely Doll. Therefore, every value that is compatible with Doll is also compatible with { kind: "doll" }. This condition returns the type in the true branch. In all other cases it returns never.

type Dolls = never | Doll | never | never

never is special in union types because it disappears. Since it is an empty set, this makes sense: If a developer creates a union of set A with the empty set, the result is set A. There are no new values added to A since, well, there are none to be added. Hence, in this example, only Doll remains.

type Dolls = Doll

Using GetKind in GroupedToys looks like this:

type GroupedToys = {
  [Kind in ToyKind]: GetKind<Toy, Kind>[]
}

GroupedToys has a dependency on ToyKind, which has a dependency on Toy. If Toy changes, so do ToyKind and GroupedToys, as shown in the listing below. The result of these artfully crafted types couldn’t be more beautiful.

type GroupedToys = {
   boardgame: BoardGame[];
   puzzle: Puzzle[];
   doll: Doll[];
   videogame: VideoGame[];
}

Using discriminated union types with a kind property is a common pattern in TypeScript. Therefore, a generic helper type to group something, no matter what kind of group, only makes sense:

type Group<Group extends { kind: string }> = {
   [Kind in Group["kind"]]: GetKind<Group, Kind>
}

type GroupedToys = Group<Toy>

Thus, GroupedToys is zero maintenance: no typos, no branches to overlook. The model receives an update once, and the application knows what is happening.