zurück zum Artikel

Low Maintenance Types in TypeScript

Stefan Baumgartner
Business,Concept,-,High,Speed,Abstract,Mrt,Track,Of,Motion

(Bild: voyata/Shutterstock.com)

Dependent types are an interesting feature in TypeScript: Not only do they show what is happening in a program. They also prepare the code for things to come.

(Dieser Artikel erschien ursprünglich in der englischsprachigen "We Are Developers!"-Beilage, die c't-Abonnenten mit der Ausgabe 13/2022 und iX-Abonnenten mit der Ausgabe 7/2022 erhalten. Weitere Informationen sind dem Ankündigungsbeitrag zu entnehmen [1].)

The ultimate goal of TypeScript is to formalize even the most versatile interfaces in JavaScript, an inherently flexible and dynamic programming language. TypeScript is a superset of JavaScript with a syntax that enables static typing. It adds a variety of operators and constructs to the language to describe those complex scenarios. But not only do they allow for basic formalism, they also let developers create dependencies between types. Those typing constructs can be used to create a set of low maintenance types: static types, which are dynamic enough to update themselves if certain conditions change.

TypeScript’s type system has one simple rule: A type defines a set of compatible values. As an example, the number 1337.42 is a compatible value of the type number. The string "Hello, World!” is compatible with the type string. Both string and number are primitive types and define a theoretically endless set of values. The primitive type boolean, on the other hand, only defines two values: true and false.

Interfaces and type aliases allow the description of complex JavaScript objects. These are called compound types, as they are usually an assembly of various properties of primitive types. The definition of a compound type also enables defining a set of compatible object values. For example, the type Person

type Person = {
  age: number,
  name: string
}

describes all objects which contain a property age of the type number, and a property name of the type string. Objects with additional properties are also compatible with the type Person. The shape of object types is important. Object types are compatible with each other if the same properties have the same types:

type Person = {
   age: number,
   name: string
}

type Student = {
  age: number,
  name: string,
  semester: number
}

const student: Student = {
  age: 24,
  semester: 7,
  name: "Jane Doe"
}

const person: Person = student // this works!

Compared to other programming languages which—with a few notable exceptions—feature nominal type systems, TypeScript features a structural one: As long as the shape of an object is equal or similar, TypeScript considers this a valid type check. A nominal type system, on the other hand, would not only ensure the shape to be the same, but also the assigned name to be the same or to be in a compatible hierarchy.

"We Are Developers!"-Beilage 2/2022

Der vorliegende Artikel stammt aus der "We Are Developers!"-Beilage 2/2022, die der c't 13/2022 und der iX 7/2022 in gedruckter Form beiliegt. Im iX-Downloadbereich ist sie als PDF kostenfrei verfügbar [2].

Die Beilage richtet sich an professionelle Softwareentwicklerinnen und -entwickler. In dieser Ausgabe behandelt sie die Zukunft von Webframeworks, mögliche Angriffe auf Machine-Learning-Systeme und Expertenmeinungen zum aktuellen und künftigen Stand der Programmiersprache Java. Zudem hält die Sommerausgabe dieses anschauliche TypeScript-Tutorial bereit.

Das Magazin entsteht in Kooperation von heise Developer mit der IT-Job-Plattform WeAreDevelopers aus Wien, an der Heise beteiligt ist. Erstmals erschien das Magazin im Vorfeld des englischsprachigen WeAreDevelopers World Congress vom 14. bis 15. Juni 2022 komplett in englischer Sprache.

The reason for using a structural type system instead of a nominal one lies in the way JavaScript works: Object literals are everywhere, so a nominal type system would only prevent developers from using what already exists.

TypeScript not only enables developers to define a set of compatible values, but also to broaden this set or to narrow the set of compatible values as they go. The intersection type is a good example. In the following code, two types are defined: Person and Studies. The type Student is an intersection of both types. It includes all values that are compatible with Person as well as with Studies, which narrows the set of possible values.

type Studies = {
	semester: number
}

type Person = {
	name: string,
	age: number
}

type Student = Person & Student

This can be visualized easily through a traditional set diagram (Figure 1).

The intersection type Student defines the intersection of Person and Studies (Figure 1).

The intersection type Student defines the intersection of Person and Studies (Figure 1).

A union type works in the opposite direction. The following listing defines groups of people that may be encountered at a university. This example does not claim to be complete.

type Professor = Person & {
	institute: string,
	employeeId: string
}

type UniversityPeople = Student | Professor
Union type, the union of Student and Professor (Figure 2)

Union type, the union of Student and Professor (Figure 2)

The union set of Student and Professor is called UniversityPeople, and is a larger set than both subsets. There is an intersection, namely professors who study, but there are also compatible values that just fall into one of the two categories (Figure 2).

TypeScript’s type system allows developers to define which values belong to which set. While union and intersection types are the most basic constructs it provides to define sets, they are just the beginning. TypeScript has much, much more up its sleeve.

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.

TypeScript’s type system is immensely powerful. Union, discriminated union, and intersection types allow for detailed modeling of data. Thanks to index access types, mapped types, conditional types, and generics, developers can create relationships across types. This brings TypeScript one step closer to describing any possible scenario of a JavaScript program with just a few lines of code. The type system does not get in the way, does not interrupt developers during their coding process, and only offers support by showing red squiggly lines where something might break.

If you want to know more about TypeScript and its type system, you can check out the author’s blog [3] and his book "TypeScript in 50 Lessons." [4] Both deal with the long lasting aspects of TypeScript and explore the depths of the type system in detail. The book’s website [5] includes a sample chapter about union and intersection types. The TypeScript playground [6] is a great place to start developing types in isolation. There you can also find all of the examples mentioned in this article [7].

Stefan Baumgartner
works at Dynatrace. He writes for Manning and A List Apart, and made the Kessel Run in less than 12 parsecs. Recently, he has published his second book "TypeScript in 50 Lessons" with Smashing Magazine. In his spare time, he co-organizes ScriptConf, DevOne, and the local Rust meetup in Linz, besides co-hosting the German language Working Draft podcast. Stefan enjoys Italian food, Belgian beer, and British vinyl records.

(mai [8])


URL dieses Artikels:
https://www.heise.de/-7145492

Links in diesem Artikel:
[1] https://www.heise.de/news/We-Are-Developers-Die-erste-internationale-Ausgabe-ist-gratis-verfuegbar-7129985.html
[2] https://www.heise.de/ix/extra/We-Are-Developers-7127640.html
[3] https://fettblog.eu/
[4] https://www.smashingmagazine.com/printed-books/typescript-in-50-lessons/
[5] https://typescript-book.com/
[6] https://www.typescriptlang.org/play
[7] https://tsplay.dev/wE5yVw
[8] mailto:mai@heise.de