Low Maintenance Types in TypeScript
Seite 2: Discriminated Union Types
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!
Mapped Types
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.
Conditional Types
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.