Blinde Flecken im Typsystem und ihre Workarounds

TypeScript am Limit

TypeScript am Limit

Blinde Flecken im Typsystem und ihre Workarounds

TypeScript am Limit


Das Typsystem von TypeScript schafft es auf bewundernswerte Weise, einen großen Teil von JavaScripts Dynamik auf nützliche Weise zu domestizieren, statt zu eliminieren. Wer TS-Code schreibt, kann beinahe die komplette Klaviatur der Webtechnologie auf typsichere Weise verwenden und wenn mal etwas nicht funktioniert, sitzt der Quell des Problems an der Tastatur. Jedenfalls meistens.

Die meisten von uns Entwickler:innen bewegen sich im Programmieralltag auf wohldefinierten Bahnen. Moderne Frameworks und Tools bieten solide Gerüste, an denen wir uns entlanghangeln können und mit denen es für fast jede Frage eine fertige Antwort oder zumindest eine Richtlinie gibt … aber eben nur fast. Die schiere Komplexität und das biblische Alter der modernen Webplattform sorgen dafür, dass die Überraschungen nie ganz verschwinden und dass es stets neue und teilweise nicht sauber lösbare Herausforderungen gibt.

Dieses Problem betrifft nicht nur Menschen, sondern auch ihre Tools und Maschinen. Niemand kann alles und schon gar nicht kann jedes Werkzeug auf jedes Problem angewendet werden. Das trifft auch auf TypeScript zu, das zwar 99 Prozent aller JavaScript-Features umfassend und korrekt beschreiben kann, aber ein gutes Prozent eben auch nicht. Und dieses Prozent besteht nicht ausschließlich aus verwerflichen Antifeatures. Manches von dem, was TypeScript nicht versteht, kann durchaus als nützliches JavaScript-Feature durchgehen und bei manch anderem Feature operiert TypeScript mit Annahmen, die nicht komplett mit der Realität in Einklang zu bringen sind.

Wie jedes Werkzeug ist TypeScript nicht perfekt und als TypeScript-Nerds sollten wir alle wissen, wo unser Tool blinde Flecken hat. Dieser Artikel nimmt sich drei dieser blinden Flecken an, stellt mögliche Workarounds vor und sinniert darüber, mit welchen Auswirkungen wir in unserem Code zu rechnen haben, sollten wir diesen blinden Flecken begegnen.

Blinder Fleck 1: Subtypen in Typparametern ausschließen

Das liskovsche Substitutionsprinzip verlangt, dass ein Programm dort, wo es einen Typen T erwartet, auch mit Subtypen von T klarkommen muss. Das abgenutzte Klischeebeispiel der Objektorientierung demonstriert dieses Prinzip immer noch am besten (Listing 1).

Listing 1: Das klassische OOP-Beispiel mit Tieren

class Dog {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
}

class Collie extends Dog {
  hair = "long";
}

let myDog: Dog = new Collie("Lassie");
// Funktioniert!

Dass eine Collie-Instanz einer Variablen vom Typ Dog zugewiesen werden kann, ergibt sehr viel Sinn, denn ein Collie ist schließlich ein Hund – eine Erweiterung des generellen Konzepts „Hund“, ein Hund mit langen Haaren als Zusatzfeature, aber definitiv ein Hund. Das Objekt, das in der Variablen myDog landet, stellt alle Funktionen bereit, die die Typannotation Dog verlangt – dass in diesem Fall das Objekt sogar noch mehr kann (lange Haare zur Schau stellen), spielt in diesem Fall keine Rolle. Aber was, wenn es eine Rolle spielt?

Dank Structural Subtyping lässt TypeScript jedes Objekt, das einen gegebenen API Contract erfüllt (bzw. ein gegebenes Interface implementiert) als „Subtyp“ durchgehen (Listing 2).

Listing 2: Structural Subtying in Aktion

class Dog {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
}

type Cat = { name: string };

let myPet: Cat = new Dog("Lassie");
// Funktioniert!

In der Web-Dev-Welt, in der Entwickler:innen nicht mühsam jedes Objekt einem Klassen-Constructor abtrotzen müssen, ist das eine sehr pragmatische Regelung, die zum einen relativ triviale semantische Fehler ermöglicht (Listing 3), aber manchmal auch weniger triviale Fallstricke aufspannt.

Listing 3: Structural Subtying löst einen Fehler aus

type RGB = [number, number, number];
let green: RGB = [0, 100, 0];

type HSL = [number, number, number];
let red: HSL = [0, 100, 50];

red = green;
// Klappt! RGB und HSL haben gleiche Struktur
// Aber ob das zur Laufzeit auch OK ist?

Betrachten wir mal eine Funktion, die einen Parameter vom Typ WeakSet<any> akzeptiert:

function takesWeakSet(m: WeakSet<any>) {}

Weak Sets [1] sind in JavaScript Sets mit speziellen Garbage-Collection-Features. Sie halten auf ihren Inhalt nur schwache Referenzen und können daher nicht Ursache von Memory Leaks sein. Im Gegenzug fehlen WeakSets diverse Features von normalen Setobjekten, primär sämtliche Iterationsmechanismen. Während normale Sets neben ihrem Job als Set auch als halbwegs universelle Listen fungieren können, können Weak Sets uns nur sagen, ob sie einen gegebenen Wert enthalten oder nicht. Dieses Feature haben normale Sets aber auch. Das bedeutet: Das API von WeakSet ist ein Subset des API von Set, womit Set als Subtyp von WeakSet zu betrachten ist (Listing 4).

Listing 4: WeakSets und Sets als Subtypen

function takesWeakSet(m: WeakSet<any>) {}

// Funktioniert offensichtlich
takesWeakSet(new WeakSet());

// Funktioniert auch, Set ist Subtyp von WeakSet
takesWeakSet(new Set());

// Aber ob das zur Laufzeit auch OK ist?

Je nachdem, was die Funktion mit ihrem Input anzustellen gedenkt, könnte das entweder gar kein Problem (wie bei Dog und Collie), ein sehr offensichtliches und leicht zu erkennendes Problem sein (wie bei RGB und HSL) oder hinterhältig-subtil zu unerwünschtem Verhalten in unserem Programm führen. Wenn takesWeakSet() damit rechnet, ein echtes WeakSet zu erhalten, würde die Funktion unter Umständen neue Werte in dem Set ablegen und davon ausgehen, dass sie sich nicht darum zu kümmern braucht, diesen Wert hinterher auch wieder zu entfernen – Weak Sets sorgen schließlich automatisch dafür, dass es keine Memory Leaks gibt! Tatsächlich kann diese Annahme aber unterlaufen werden, wenn Set als Subtyp von WeakSet gilt.

Wir sehen also, dass wir oft, aber nicht immer Subtypen eines gegebenen Typs einfach so akzeptieren sollten. Das auch zu implementieren ist für dieses konkrete Beispiel gar nicht mal so schwer, allerdings nicht wirklich zu generalisieren.

Subtypen müssen leider draußen bleiben

Mit Typ-Level-Programmierung ist es vergleichsweise einfach, einen Typ zu konstruieren, der einen anderen Typ akzeptiert, aber dessen Subtypen ablehnt. Das Schlüsselwerkzeug hierfür sind generische Typen, die wir wie Typfunktionen betrachten können (Listing 5).

Listing 5: Generische Typen als Typfunktionen

// Typfunktion, die den Parameter T
// in ein Array einpackt
type Wrap<T> = [T];
  
// Entsprechende JS-Funktion, die den
// Parameter t in ein Array einpackt
let wrap = (t) => [t];

In generischen Typen können wir Conditional Types verwenden, die genau wie der Ternary-Operator in JavaScript funktionieren (Listing 6).

Listing 6: Conditional Types

// A extends B = "ist A Subtyp von B?"
// Anders formuliert: "ist A Subtyp von B?"
type Test<T> = T extends number ? true : false;

type A = Test<42>; // true (42 ist number zuweisbar)
type B = Test<[]>; // false ([] ist number nicht zuweisbar)

Derart ausgerüstet können wir nun eine Typfunktion formulieren, die zwei Typparameter akzeptiert und uns verrät, ob der erste Parameter exakt dem Typ des zweiten Parameters entspricht. Das ist nur der Fall, wenn der erste Parameter dem zweiten und der zweite Parameter dem ersten zuweisbar ist. Trifft eine der beiden Bedingungen nicht zu, muss entweder der erste Parameter ein Subtyp des Zweiten, der zweite ein Subtyp des Ersten, oder beide Parameter gänzlich inkompatibel sein. In Code sieht das wie in Listing 7 aus.

Listing 7: ExactType<Type, Base>

type ExactType<Type, Base> =
  Type extends Base
    ? Base extends Type
      ? Type
      : never
    : never;

type A = ExactType<WeakSet<any>, WeakSet<any>>;
// Ergebnis WeakSet<any> - A und B sind gleich

type B = ExactType<Set<any>, WeakSet<any>>;
// Ergebnis never - A ist Subtyp von B

type C = ExactType<WeakSet<any>, Set<any>>;
// Ergebnis never - B ist Subtyp von A

type D = ExactType<WeakSet<any>, string>;
// Ergebnis never - A und B sind inkompatibel

Der Typ never, den wir hier zum Modellieren des Falls „Type und Base sind unterschiedlich“ verwenden, ist ein Typ, dem kein Wert zugewiesen werden kann. Jeder Datentyp ist wie ein Set von möglichen Werten (z. B. ist number das Set aller Zahlen und Array<string> das Set aller mit Strings befüllten Arrays) und never ist ein leeres Set. Kein Fehler, keine Exception – never steht für einfach gar nichts.

ExactType<Type, Base> können wir nun nutzen, um takesWeakSet() so umzurüsten, dass es nur Weak Sets akzeptiert. Wir müssen die Funktion dafür lediglich generisch machen und dann den Typ für den Wertparameter m mit ExactType definieren (Listing 8).

Listing 8: ExactType<Type, Base> in Aktion

type ExactType<Type, Base> =
  Type extends Base
    ? Base extends Type
      ? Type
      : never
    : never;

function takesWeakSet<T>(m: ExactType<T, WeakSet<any>>) {}

// Funktioniert offensichtlich
takesWeakSet(new WeakSet());

// Funktioniert nicht mehr!
takesWeakSet(new Set());

Der Grund für das Nichtfunktionieren des Aufrufs mit dem normalen Set liegt darin, dass ExactType<Type, Base> hier den Typ never als Ergebnis errechnet, und da kein Wert (und schon gar kein Set-Objekt) in never passt, meckert der TypeScript-Compiler an dieser Stelle wie gewünscht. Problem gelöst?

Der schwierige Subtypausschluss in Typparametern

Wenn wir, wie vorhin behauptet, generische Typen wie Funktionen betrachten können, sollte es möglich sein, die Features der Runtime-Funktion takesWeakSet() als Typfunktion zu reproduzieren, oder? Stand jetzt macht die Funktion ja nicht mehr, als einen auf einen exakten Subtyp festgelegten Parameter zu akzeptieren – das sollte doch möglich sein! Das Gerüst, ein generischer Typ mit einem Typparameter, ist schnell gebaut:

type TakesWeakSet<M> = {};

Nun benötigen wir, so bizarr es auch scheint, einen Typ für den Typen T, denn aktuell kann hier noch jeder beliebige Datentyp eingesetzt werden. Doch kein Problem: extends-Klauseln dürfen nicht nur in Conditional Types, sondern auch als Beschränkung für Typparameter eingesetzt werden:

type TakesWeakSet<M extends WeakSet<any>> = {};

Damit ist die Typfunktion auf dem gleichen Stand, wie takesWeakSet() zu Beginn war: Sie ist eine einstellige Funktion mit einer Typannotation, die eine Minimalanforderung an den Input formuliert. Subtypen werden weiterhin akzeptiert (Listing 9).

Listing 9: Subtypen als Input

type TakesWeakSet<M extends WeakSet<any>> = {};

// Funktioniert offensichtlich
type A = TakesWeakSet<WeakSet<any>>;

// Funktioniert auch, Set ist Subtyp von WeakSet
type B = TakesWeakSet<Set<any>>;

Aber kein Problem, genau für diesen Zweck haben wir doch ExactType geschrieben, oder? Im Prinzip schon, aber es gibt einen fundamentalen Unterschied zwischen der Typfunktion TakesWeakSet<M> und der Runtime-Funktion takesWeakSet(m). Letztere hat nämlich, wenn wir es ganz genau nehmen, einen Parameter mehr als erstere (Listing 10).

Listing 10: Typ- und Runtime-Funktion im Vergleich

// Ein Parameter "M"
type TakesWeakSet<M extends WeakSet<any>> = {};

// Ein Parameter "m" UND ein Typparameter T
function takesWeakSet<T>(m: ExactType<T, WeakSet<any>>) {}

Ein Aufruf der Runtime-Funktion takesWeakSet() übergibt zwei Parameter: einen Typparameter und einen Wertparameter. Der Typparameter dient als Input für die Errechnung des Typs des Wertparameters, wo, falls ExactType als Ergebnis never liefert, sich ein Fehler manifestiert. Die Typfunktion ExactType ist der eigentliche Schlüssel zum Ausschließen von Subtypen. Dieser Kniff lässt sich auf Typebene nicht reproduzieren, denn selbstreferenzielle Typparameter sind (von wenigen, für uns nicht hilfreichen Sonderfällen abgesehen) nicht erlaubt (Listing 11).

Listing 11: Keine selbstreferenziellen Constraints in Typparametern

// Fehler: "Type" darf nicht Input für
// seine eigenen Beschränkungen sein
type TakesExact<Type extends ExactType<
  Type,
  WeakSet<any>>
> = {};

Funktionieren würde hingegen, die Logik von ExactType in TakesExact zu verfrachten. Das würde aber nicht in der Ablehnung von Subtypen münden, sondern dieses lediglich nach never übersetzen – es gibt also keinen Fehler, nur ein (vermutlich) nicht nützliches Ergebnis (Listing 12).

Listing 12: TakesExact mit der Logik von ExactType

type TakesExact<Type> = Type extends WeakSet<any>
  ? WeakSet<any> extends Type
    ? Type
    : never
  : never;

type R1 = TakesExact<WeakSet<{}>>;
// OK, R1 = WeakSet<{}>

type R2 = TakesExact<Set<string>>;
// OK, R2 = never (KEIN Fehler)

type R3 = TakesExact<Array<string>>;
// OK, R3 = never (KEIN Fehler)

Wie wir es drehen und wenden: Auf Typebene Parameter abzulehnen, die Subtypen eines gegebenen Typs sind oder einen exakten Typ zu erzwingen, ist nicht möglich – TypeScript hat hier einen blinden Fleck. Aber ist das wirklich ein Problem?

Wie gehen wir mit Subtypeinschränkungen auf Typebene um?

Die goldene Regel des Programmierens in statisch typisierten Sprachen lautet: „Make invalid states unrepresentable.“ Wenn es Entwickler:innen unmöglich ist, Code so zu schreiben, dass er das Programm auf Abwege führt (z. B. indem exakte Typannotationen ungültige Inputs unmöglich machen), wird viel Zeit mit dem Debugging unerw...