Ambiguitätstoleranz, Löcher und Constructor-Funktionen

Veröffentlicht am 10. November 2020

Fast jede größere Ansammlung von Mainstreamsprachen-Programmcode zeigt Phänomene, die ich als Löcher bezeichne. Löcher manifestieren sich im Zuge der Umsetzung von Konzepten in konkreten Code, sind nicht notwendigerweise Bugs, haben oft mit Objekten zu tun und lassen sich nicht loswerden – sie erwachsen aus Tradeoffs beim Sprachdesign und bestehen aus (möglichem) unerwünschtem Verhalten des aus dem Code resultierenden Programms. Ich halte es für wichtig, dass wir als Nutzer von Mainstream-Programiersprachen wie JavaScript und TypeScript solche Löcher erkennen und damit einen souveränen Umgang pflegen. Was nach meiner Auffassung bedeutet, sie einfach zu tolerieren.

Um als Loch zu gelten, muss ein Stück Code ein subtileres (potenzielles) Problem aufweisen als beispielsweise ein Fall-Through in einem Switch-Statement und es darf sich auch nicht durch ein angeflanschtes Typsystem wie etwa TypeScript reparieren lassen. Vielmehr geht es um unerwünschtes Verhalten, das sich aus bestimmten Patterns oder Sprachfeatures ganz automatisch ergibt. Mein persönliches Lieblingsloch sind Constructor-Funktionen in JavaScript bzw. TypeScript. Nehmen wir doch zu Demonstrationszwecken ein allgemein verständliches, wenn auch an den Haaren herbeigezogenes, praxisfernes Simpel-JavaScript-Beispiel her:

class Car {
  #kilometers;
  #gear;

  constructor () {
    this.#kilometers = 0;
    this.#gear = 0;
  }

  drive (km) {
     if (typeof km !== "number" || km < 0) {
      throw new Error();
    }
    this.#kilometers += km;
  }

  shift (gear) {
    if (typeof gear !== "number" || gear < -1 || gear > 5) {
      throw new Error();
    }
    this.#gear = gear;
  }

  get kilometers () {
    return this.#kilometers;
  }

  get gear () {
    return this.#gear;
  }

}

Eine der zentralen Ideen hinter objektorientierter Programmierung ist, dass Objekte ihren internen Zustand vor der Außenwelt verbergen und Modifikationen des Zustands nur über Methoden erlauben. In der obigen Beispielklasse sind die Felder für #gear und #kilometers privat und können nur über die Methoden shift() und drive() indirekt verändert werden. Die Methoden fangen ungültige Inputs ab und stellen damit sicher, dass unser Kilometerzähler stets nur wächst und dass wir keinen Gang einlegen, den wir nicht zur Verfügung haben. Es gibt also eine endliche Menge an Zuständen, die ein Auto-Objekt einnehmen kann und daher nur eine endliche Menge von Fällen, über die wir uns für unser Objekt Gedanken machen müssen:

// Ein möglicher Zustand
let a = { #kilometers: 0, #gear: 2 }

// Ein weiterer möglicher Zustand
let b = { #kilometers: 42, #gear: 0 }

// Ein unmöglicher Zustand, den wir nicht beachten müssen
let c = { #kilometers: 42, #gear: -7 }

// Ein weiterer unmöglicher Zustand
let d = { #kilometers: 42, #gear: 0, #asdf: "Hi!" }

Jede Instanz der Auto-Klasse ist also stets in einem wohldefinierten Zustand mit einem validen Gang, positiver Kilometerzahl und nichts anderem, womit jede dieser Instanzen im Prinzip eine solide State Machine darstellt. Oder?

Es gibt in der Klasse ein Loch, in dem das Auto tatsächlich nicht in einem wohldefinierten Zustand ist – und zwar im Constructor! Die Methoden shift() und drive() bewerkstelligen die Übergänge von einem gültigen Zustand unseres Auto-Objekts in den nächsten gültigen Zustand und prüfen dafür die Inputs, nehmen aber einfach an, dass die Ausgangszustände jeweils auch gültig sind. Diese Annahme müssen die Methoden auch treffen, des wäre unverhältnismäßiger Aufwand, den Vorher-Zustand des Objekts in jedem Methodenaufruf zu validieren und solange jede Methode einen neuen gültigen Zustand produziert (und nur Methoden Zustände erzeugen können), ist das auch nicht nötig. Allerdings gilt die Annahme eines gültigen Objektzustandes nicht im Constructor! Bevor wir this.#kilometers und this.#gear erstmals definieren, sind sie undefined und damit ist unser Auto in einem eindeutig nicht-wohldefinierten Zustand:

class Car {
  #kilometers;
  #gear;

  constructor () {
    // Bis zu dieser Stelle ist "#kilometers" undefined
    this.#kilometers = 0;
    // Bis zu dieser Stelle ist "#gear" undefined
    this.#gear = 0;
  }

  // drive, shift usw.
}

Besonders überraschend ist das nicht, denn der Constructor ist ja gerade dafür da, unser Objekt erstmals zu konstruieren, und was nicht fertig konstruiert ist, ist noch nicht in einem nicht-wohldefinierten Zustand (es sei denn wir nehmen die undefined-Fälle in die Liste der von uns als gültig betrachteten Zustände auf, was diese Liste allerdings so umfangreich machen würde, dass ihr praktischer Nutzen dahin geht). Diese nicht-wohldefinierten Zustände mitten in einem Sprachkonstrukt, das genau diese Zustände verhindern soll (Klasse), ist was ich mit „Loch“ meine. Es ist eine Lücke in unseren Annahmen (z. B. „sauber konstruiere Klasse === State Machine“) und jenen Maßnahmen, die eigentlich rund um die Vermeidung solcher Lücken in unserer Auto-State-Machine kreisen (z. B. sauber konstruierte Methoden). Ein solches Loch muss keinen Bug auslösen, aber falls es der Constructor schafft, das Objekt in einen nicht-vorgesehenen Zustand zu versetzen, könnten die Methoden (deren Kern-Annahme ist, gültige Ausgangszustände vorzufinden) in Schwierigkeiten kommen und Bugs zutage treten lassen.

Solche Löcher lassen sich im Allgemeinen nicht stopfen. Natürlich könnten wir in unserem simplen Beispiel den nicht-wohldefinierten Zustand (bzw. die vielen verschiedenen undefinierten Zustände) entfernen, indem wir den Constructor löschen und die Felder #gear und #kilometers anderweitig initialisieren:

class Car {
  #kilometers = 0; // Deklaration PLUS initialisierung
  #gear = 0; // Deklaration PLUS initialisierung

  // KEIN Constructor mehr
  // drive, shift usw.
}

Damit ist aber weniger das Loch an sich gestopft, als vielmehr ein löchriges Bauteil entfernt worden. Der Constructor war in diesem Fall überflüssig und daher können wir das Loch zusammen mit dem Constructor loswerden. Sobald der Constructor aber nicht überflüssig ist, weil er z. B. Parameter empfängt und validiert …

class Car {
  #kilometers;
  #gear;

  constructor (km = 0) {
    if (typeof km !== "number" || km < 0) {
      throw new Error();
    }
    this.#kilometers = km;
    this.#gear = 0;
  }

  // drive, shift usw.
}

… haben wir wieder einen Programmteil, in dem das Objekt nicht wohldefiniert ist. Natürlich könnten wir auch hier wieder versuchen einen Workaround zu schaffen, indem wir die privaten Felder bei ihrer Initialisierung mit Default-Werten initialisieren, die später überschrieben werden:

class Car {
  #kilometers = 0; // brauchbarer Default
  #gear = 0;       // brauchbarer Default

  constructor (km = 0) {
    if (typeof km !== "number" || km < 0) {
      throw new Error();
    }
    this.#kilometers = km;
  }

  // drive, shift usw.
}

Aber das lässt sich nicht ohne weiteres generalisieren! Für die meisten Zahl-Felder mag 0 ein brauchbarer Standardwert sein, gerade wenn er wie in unserem Beispiel innerhalb des gültigen Wertebereichs für Kilometer und Gänge liegt. Was ist aber mit Feldern, für die es keinen selbsterklärenden Standard gibt und die, weil von irgendwelchen Inputs und Validierungen abhängig, erst im Constructor festgelegt werden?

class Car {
  #kilometers = 0;
  #gear = 0;
  #seats; // was könnte hier der Standard sein?

  constructor (seats, km = 0) {
    if (typeof km !== "number" || km < 0) {
      throw new Error();
    }
    this.#kilometers = km;
    if (typeof seats !== "number" || seats < 1) {
      throw new Error();
    }
    this.#seats = seats;
  }

  // drive, shift usw.
}

Das Feld #seats ist auch eine Zahl und wenn wir nur auf die Datentypen schauen, könnten wir vielleicht 0 für einen „validen“ Wert halten, aber semantisch ist ein Auto ohne Sitzplätze fragwürdig. Es wäre korrekter, die Sitzplätze als nicht definiert zu betrachten, solange wir keinen entsprechenden User-Input erhalten und validiert haben. Aber damit ginge wieder ein Constructor-Loch einher.

Statt sich an weiteren klapprigen und/oder nicht-generalisierbaren Workarounds zu probieren, finde ich es sehr viel sinnvoller, das (mögliche) Loch, dass ein Constructor darstellt, als gegeben zu akzeptieren und damit zu leben. Wenn wir hinnehmen, dass ein Objekt innerhalb des Constructors in einem nicht-wohldefinierten Zustand sein kann (bzw. ist, denn sonst wäre der Constructor ja überflüssig), folgt daraus eigentlich nur eine Regel: keine Methoden im Constructor verwenden! Methoden besorgen einen Übergang von wohldefiniertem Zustand A zu wohldefiniertem Zustand B, aber wenn wir im Constructor sind, gibt es eben keinen wohldefinierten Zustand A. Halten wir uns an diese einfache Regel, ist das Loch im Constructor nichts, was uns stören kann, sondern es ist sogar nützlich – wir können einfach einen Ausgangszustand für unser Objekt herstellen, indem wir ein paar Zeilen imperativen Code schreiben und diesen gründlich testen. Es ist im Prinzip ein praktischer Anwendungsfall für Ambiguitätstoleranz. Unsere Klasse kann sowohl eine saubere State Machine sein als auch ungültige Zustände (in engen Grenzen) erlauben. Und solange wir jeweils wissen, wann welchen Garantien gelten und wann nicht, ist das auch gar kein Problem.

TypeScript hilft im Übrigen an dieser Stelle auch kein bisschen weiter, das Constructor-Loch besteht weiterhin und kann sich bemerkbar machen:

class Car {

  // Anname: jede Car-Instanz hat immer einen numerischen kilometers-Wert
  private kilometers: number;

  constructor (km: number) {
    // hier this.kilometers auszulesen lässt das Typesystem nicht zu,
    // aber was sehr wohl geht ist...
    this.accessKilometers();
    this.kilometers = km;
  }

  private accessKilometers () {
    console.log(this.kilometers); // undefined
  }

}

Wie wir es auch drehen und wenden: ein Constructor ist nun mal dafür da, einen initialen wohldefinierten Zustand eines Objekts herzustellen und das bringt mit sich, dass vor und während dieses Prozesses kein wohldefinierter Zustand vorhanden ist. Ein solches Loch ist also kein Programmierfehler unserseits, im Wesen eines Constructors als solchem begründet! Der Constructor selbst, bzw. die Möglichkeit, ein Objekt auf imperative Weise zu konstruieren, ist das Loch, nicht unsere Benutzung des Constructors.

Löcher gibt es aber nicht nur in Klassen, sondern auch in normalen Funktionen. Diese fallen gerade in TypeScript zwar gern auf, aber lassen sich nicht sinnvoll stopfen:

type Car = {
  kilometers: number;
  gear: number;
}

function makeObject <T extends object> (...entries: [string, any][]): T {
  let result = {};
  for (const [property, value] of entries) {
    result[property] = value;
  }
  return result;
}

const myCar: Car = makeObject<Car>(["kilometers", 42], [ "gear", 0 ]);

Dieser Code ist kein valides TypeScript und ist auch nicht ohne weiteres zum Funktionieren zu bringen:

  • Im Jetzt-Zustand hat result den Typ {}, weswegen die Zeile result[property] = value nicht funktioniert – der Typ {} hat schließlich keine Properties!
  • Hätte result den Typ T, dürfte es nicht mit {} initialisiert werden
  • Hätte result einen anderen Typ als T wie z. B. Partial<T>, würde es nicht zum Rückgabetyp T der Funktion passen

Auch wenn wir für entries etwas weniger laxes als [string, any][] einsetzen ändert das nichts am Grundproblem: in der Funktion entsteht das Objekt vom Typ T gerade erst, weswegen es vor Durchlauf der letzten Schleifeniteration prinzipbedingt noch kein T sein kann. Es handelt sich im Wesentlichen um das gleiche Loch wie im Constructor – um imperativen Objektzusammenbau.

Löcher wie im JavaScript-Klassenconstructor oder dem iterativen Zusammenbau von TypeScript-Objekten kommen aus Eigenschaften der Programmiersprachen selbst. Es gibt andere Sprachen (z. B. Haskell und Rust), in denen solche Löcher wesentlich seltener auftreten und in denen durch ausgefuchste Typsysteme oder das Fehlen bestimmter Sprachkonstrukte das Mantra „make illegal states unrepresentable“ tatsächlich umsetzbar ist – um mit JavaScript auch dorthin zu kommen, müssten wir Sprachkonstrukten wie Constructor-Funktionen abschwören und uns auf Objektliterale beschränken.

Diese tendenziell lochfreien Programmiersprachen sind aber noch eher jenseits des Mainstreams zu finden und auch weit weniger einfach zu lernen als etwa JavaScript und TypeScript&nsbp;– Gründlichkeit hat nun mal einen Preis. Es ist extrem einfach, in einem Klassenconstructor aus dem Nichts ein Objekt zu initialisieren oder in TypeScript mithilfe von any einen Record zusammenzubasteln, und diese Einfachheit ist viel wert. Löcher sind lediglich die Kehrseite dieser Einfachheit. Löcher gilt es zu erkennen, zu tolerieren und mögliche Probleme sollten weitsichtig umschifft werden, z.B. durch die Anwendung der Regel „keine Methodenaufrufe im Constructor“.

Wie groß ist meine TypeScript-Union?

Veröffentlicht am 29. September 2020

Im Rahmen meiner TypeScript-Hackerei hatte ich schon mehrfach den Wunsch nach einem Hilf-Typ, mit dem sich ermitteln lässt, wie viele Elemente in einem gegebenen Union-Typ stecken. Alles, was sich hierzu ergooglen lässt, ist entweder auf Unions bestimmter Größen limitiert oder Bestandteil irgendwelcher komplizierten Typ-Libraries. Ich wollte aber eine saubere und von mir selbst zu 100% verstandene Standalone-Lösung haben, also beschloss ich Freitag letzter Woche, dem Thema ein für allemal auf den Grund zu gehen. Da ich nur eine extrem vage Idee davon hatte, wie sich dieses Problem lösen lassen könnte, bediente ich mich des zielgerichteten explorativen Programmierens, das ich meist im Rückwärtsgang betreibe.

Exploratives Programmierens im Rückwärtsgang (also bei bekanntem Ziel aber unbekannten Mitteln und Wegen) beginnt für mich immer mit DDD: Dreamcode-Driven Development. Ich schreibe also erst mal ein bisschen Code, der das benutzt, was ich eigentlich überhaupt erst bauen möchte. Mein Dreamcode für SizeOfUnion<T> sieht wie folgt aus:

type Result = SizeOfUnion<23 | 42 | 1337>
// Result ist 3

So weit, so logisch: in der Union befinden sich drei Typen, also ist das Ergebnis der Literal Number Type 3. Nur wie kommen wir an die 3 heran? Am einfachsten geht das über ein Tuple, denn diese haben durch ihre feste Anzahl an Einträgen nicht nur ein length-Feld mit einer readonly number, sondern tatsächlich einen Readonly Literal Type:

type One = [number]["length"]; // One === 1
type Two = [number, string]["length"]; // Two === 2
// Und so weiter

So gesehen ist also die Implementierung von SizeOfUnion<T> ganz einfach: wir brauchen nur einen Hilfs-Typ, der aus einer Union mit N Bestandteilen ein Tuple der Größe N macht, und N fragen wir am Ende ab:

type SizeOfUnion <T> = UnionToTuple<T>["length"];

type Result = SizeOfUnion<23 | 42 | 1337>
// Result ist 3

Jetzt fehlt „nur“ noch UnionToTuple<T> und schon haben wir unser Ziel erreicht. Allerdings ist gerade dieser Schritt nicht ganz so einfach. Wir müssen uns zunächst ein paar Fakten zu Funktionssignaturen vergegegenwärtigen, auch wenn diese zunächst nichts mit unserem Problem zu tun haben scheinen.

Fangen wir mit einer scheinbar trivialen Quizfrage an: wie könnten wir den Typ der folgenden Funktion in TypeScript-Typ-Syntax aufschreiben?

function f (x: number): number;
function f (x: string): string;
function f (x: any): any { return x; };

Da die Funktion f überladen ist, gibt es gleich zwei richtige Antworten:

// Überladungs-Signatur
type F1 = {
    (x: number): number;
    (x: string): string;
};

// Intersection Type
type F2 = ((x: number) => number) & ((x: string) => string);

const f: F1 = (x: any) => x;
const a = f(1);    // Ok, a ist number
const b = f("a");  // Ok, b ist string
const c = f(true); // Fehler

const g: F2 = (x: any) => x;
const x = g(1);    // Ok, x ist number
const y = g("a");  // Ok, y ist string
const z = g(true); // Fehler

Anders formuliert: Das Überladen einer Funktion definiert die Funktionssignatur als aus den einzelnen Funktionssignaturen zusammengesetzten Intersection Type! Daraus ergibt sich natürlich sofort die nächste Frage: wie lässt sich der Parameter-Typ einer solchen Funktionssignatur beschreiben? TypeScripts Antwort sieht wie folgt aus:

type Test = ((x: 23) => void) & ((x: 42) => void);
type Params = Parameters<Test> // [42]

Gar keine Spur von der 23? Warum ist das denn so? Der in TS standardmäßig verbaute Typ-Helfer Parameters<T> ist ein recht simpler Conditional Type …

type Parameters<T extends (...args: any) => any> =
  T extends (...args: infer P) => any
    ? P
    : never;

… und zum Gebrauch von infer bei überladenen Funktionen lässt uns das TypeScript-Handbuch wissen:

When inferring from a type with multiple call signatures (such as the type of an overloaded function), inferences are made from the last signature (which, presumably, is the most permissive catch-all case).

Das liest sich auf den ersten Blick wie eine Limitierung, ist aber eigentlich ein ganz großartiges Feature: Wir können mittels infer immer etwas aus der letzten Funktionssignatur extrahieren. Und wann immer wir aus einer „Liste“ von Elementen (in diesem Fall den Einzelteilen eines Intersection Type) den letzten Eintrag greifen können, können wir diesen Eintrag verarbeiten und den Rest der Liste der gleichen Prozedur unterziehen –das Zauberwort heißt Rekursion! Einmal im Kopf durchgedacht könnte der Prozess wie folgt aussehen

  1. Ausgehend von einer Union U = 23 | 42 …
  2. …erzeugen wir (auf welchem Weg auch immer) eine überladene Funktionssignatur ((x: 23) => void) & ((x: 42) => void) …
  3. …von der wir per Conditional Type F extends ((a: infer A) => void) für den Parameter A den Parameter-Typ aus der letzten Funktionssignatur (Literal Number Type 42) extrahieren …
  4. … den wir per Exclude<U, A> aus U ausschließen, so dass nur 23 in der Union verbleibt …
  5. …woraus wir die Funktionssignatur (x: 23) => void erzeugen …
  6. …von der wir per Conditional Type F extends ((a: infer A) => void) für den Parameter A den Parameter-Typ aus der letzten und nun einzigen Funktionssignatur (Literal Number Type 23) extrahieren …
  7. … den wir per Exclude<U, A> aus U ausschließen, so dass die resultierende Union leer ist …
  8. …woraus sich keine weitere Funktionssignatur bauen lässt und wir jeden Wert der Eingangs-Union je einmal in der Hand hatten (um daraus z.B. ein Tuple zu konstruieren, auf welchem Weg auch immer).

Das sollte doch zu machen sein! Kümmern wir uns zunächst darum, aus einer Union einen Intersection Type zu basteln. Wie das genau geht, hat Podcast-Kollege Stefan Baumgartner erst vor kurzem ausführlich aufgeschrieben:

// https://fettblog.eu/typescript-union-to-intersection/
type UnionToIntersection <T> = 
  (T extends any ? (x: T) => any : never) extends 
  (x: infer R) => any ? R : never;

Wie genau diese Hexerei funktioniert hat Stefan so umfassend erklärt, dass es an dieser Stelle keiner weiteren Worte bedarf – wir füttern in sein Werk einfach Funktionssignaturen hinein um diese zu einer überladenen Signatur zu vereinigen:

type Test = UnionToIntersection<((a: 23) => void) | ((a: 42) => void)>
// Test === ((a: 23) => void) & ((a: 42) => void)
// genau was wir brauchen!

Hierfür müssen wir allerdings erst mal aus unserer normalen Union eine Union aus Funktionssignaturen basteln, bei je ein Typ aus der Ausgangs-Union den ersten Parameter in einem Typ in der Endprodukt-Union stellt. Einfach UnionToIntersection<(a: Union) => void> funktioniert nicht, denn hier erhält UnionToIntersection<T> für T nur eine Union der Größe 1, worin sich ein Funktionstyp befindet, der unserer eigentlichen Union Parameter erwartet. Wir kommen nicht umhin, unsere Union einmal durch einen scheinbar nutzlosen Conditional Type zu schleifen:

type UnionToIntersection<T> =
    (T extends any ? (x: T) => any : never) extends
    (x: infer R) => any ? R : never

type UnionToFunction<U> = UnionToIntersection<
    U extends any ? (f: U) => void : never>;

type Test = UnionToFunction<23 | 42>;
// Test === ((f: 23) => void) & ((f: 42) => void)
// genau was wir brauchen!

Die Schlüsselzeile ist U extends any ? (f: U) => void : never. Indem wir die Union U durch ein Conditional schleifen (auch wenn es mit der Bedingung extends any nichts ausschließt), wird jeder Typ in der Union einzeln verarbeitet – das Stichwort lautet Distributive Conditional Types. Ausgehend von unserer Union 23 | 42 wird also zunächst 23 für sich allein an der Bedingung extends any geprüft und, da 23 diese natürlich erfüllt, in einer Funktion verpackt. Danach passiert das Gleiche mit 42 und die beiden Funktionen bilden die Endprodukt-Union, die von UnionToIntersection<T> in eine Intersection verwandelt werden.

Kurzes Zwischenfazit: aus einer beliebigen Union machen wir eine Union aus Funktionstypen, bei der für jedes Element in der Ausgangs-Union ein Funktions-Typ in der Funktions-Union existiert, der das Element aus der Ausgangs-Union als Parameter erwartet. Diese Union aus Funktionstypen schweißen wir zu einem Intersection Type zusammen, der dem Typ einer überladenen Funktion entspricht. Und diesem rücken wir nun rekursiv zu Leibe, um endlich aus einer Union ein Tuple zu konstruieren! Im Prinzip ist dieser letzte Schritt ganz einfach:

type UnionToTuple<U> = UnionToFunction<U> extends ((a: infer A) => void)
    ? [...UnionToTuple<Exclude<U, A>>, A]
    : [];

Dieser Typ funktioniert ganz wie besprochen:

  1. Aus der Input-Union U mit N Elementen wird ein Intersection Type aus N Funktionssignaturen konstruiert …
  2. …woraus wir mittels infer den Parameter-Typ A des letzten Elements in der Intersection herauspicken …
  3. …woraufhin wir A an die letzte Stelle des Output-Tuples setzen und den Rest des Tuples via Variadic Tuple Types mit UnionToTuple<Exclude<U, A>> auffüllen (d.h. UnionToTuple arbeitet mit N-1 Elementen).

Wenn die Union komplett durchgearbeitet wurde, liefert UnionToTuple<T> ein leeres Tuple und die Rekursion ist beendet.

Der einzige Haken an dieser Lösung ist, dass TypeScript 4.0 keine Rekursion in Conditional Types erlaubt, doch die kommende Version 4.1 hat bereits einen Patch für dieses Feature erhalten. Mit der aktuellen Vorab-Version von 4.1 funktioniert schon alles wie es soll:

type UnionToIntersection<T> =
    (T extends any ? (x: T) => any : never) extends
    (x: infer R) => any ? R : never;

type UnionToFunction<U> = UnionToIntersection<
    U extends any
        ? (f: U) => void
        : never>;

type UnionToTuple<U> =
    UnionToFunction<U> extends ((a: infer A) => void)
        ? [...UnionToTuple<Exclude<U, A>>, A]
        : [];

type SizeOfUnion <T> = UnionToTuple<T>["length"];

type Result = SizeOfUnion<23 | 42 | 1337>
// Result ist 3

Link zum TypeScript-Playground mit diesem Beispiel.

Der Vollständigkeit halber sei noch erwähnt, dass sich die Transformation, die die Kombination aus UnionToFunction<T> und UnionToIntersection<T> vollzieht, auch in einem Schritt abfrühstücken ließe:

type UnionToFunction<T> =
    (T extends any ? ((f: (a: T) => any) => any) : never) extends
    (a: infer R) => any ? R : never

Da das allerdings an die Grenzen der Verständlichkeit geht und UnionToIntersection<T> für sich genommen schon ein wertvolles Tool ist, würde ich das nicht so machen.

Jenseits von allem, das uns dieses Ergebnis über TypeScript lehrt, finde ich es auch ein schönes Beispiel für Dreamcode Driven Development. Wenn man ein Ziel, aber wirklich gar keine Ahnung hat, wie man es erreichen könnte, lohnt es sich fast immer, einfach mal vom Ziel aus rückwärts drauflos zu hacken – so lange, bis man auf etwas stößt, mit dem man sich auskennt und auf das man dann auch von der anderen Seite aus hinarbeiten kann. Nachdem ich kapiert hatte, dass infer mit überladenen Funktionstypen die Tür zur Rekursion öffnet und mich dabei obendrein dunkel an Stefans Artikel über Union-Intersection-Umwandlungen erinnerte, war mir recht klar wie sich mein Problem lösen lassen würde.

Wenn ich jetzt noch wüsste, wofür ich am Freitagmorgen das Problem eigentlich lösen wollte …

Fragen zu HTML5 und Co beantwortet 27 - native Tabs, Conditional Types, HTML-Imports, Top-Level Async

Veröffentlicht am 4. August 2020

Auch in Zeiten der Seuche herrscht an Webentwicklungs-Fragen kein Mangel und aufgrund von fortgesetzter coronabedingter Arbeitslosigkeit habe ich beschlossen, mein beachtliches Backlog in Angriff zu nehmen! Wenn auch ihr Fragen zu Frontend-Themen aller Art habt, stellt sie mir per E-Mail oder Twitter und ich verspreche zeitnahe Antworten!

Warum gibt es kein HTML-Element für Tab-Widgets?

Warum gibt es eigene HTML-Elemente für Fortschrittsbalken und das Details-Element für Klapp-Dialoge, aber kein eingebautes Tab-Widget-Element?

Es gibt einen fundamentalen Unterschied zwischen einem Tab-Widget und dem <details>-Element und dieser Unterschied ist die vermutlich beste Erklärung dafür, warum ersteres nicht im HTML-Standard ist (und meiner Prognose nach dort auch nie landen wird): ein Tab-Widget ist ein sehr konkretes und komplexes UI-Konzept, während <details> extrem abstrakt bzw. allgemein spezifiziert ist. Um die Spezifikationen zu zitieren:

The details element represents a disclosure widget from which the user can obtain additional information or controls.

Das <details>-Element ist mitnichten ein „Element für Klapp-Dialoge“, sondern definiert einen Container für ein- und ausblendbare Informationen und damit ein extrem allgemeines Konzept. Eine konkrete Umsetzung der damit verbundenen User Experience ist nicht vorgeschrieben und es ist nur mehr oder minder zufällig so, dass alle bekannten Browser das Element als Klapp-Dialog umsetzen. Die Definition von <details> lässt aber auch viele andere Umsetzungen zu, was für Screenreader, Terminal-Anwendungen und vor allem für zukünftige UI-Konzepte, die es 2020 noch gar nicht gibt, sehr wichtig ist. Die Webplattform konnte nur über 30 Jahre relevant bleiben, indem von der <h1> bis hin zu <details> alles so flexibel und zukunftssicher (man könnte auch sagen: unkonkret) gehalten wurde, dass alle neuen Entwicklungen wie z.B. Smartphones mitgegangen werden konnten. Ein weiteres Beispiel für solch allgemeine Spezifizierungen sind die HTML5-Formularelemente. So ist z.B. für das <input type="date"> nicht vorgeschrieben, wie ein dazugehöriger Datumspicker aussehen soll. Die Browser können selbst entscheiden und daher für Desktop wie Mobiltelefon jeweils passende Interfaces auswählen.

Ein hypothetisches Tab-HTML-Element ist im Prinzip auch nur ein weiteres „disclosure widget“ wie <details>-Element, aber konzentriert sich mit dem Fokus auf ein Tab-Interface schon sehr auf eine konkrete User Experience. Diese so zu spezifizieren, dass sie zum einen so allgemein und anpassungsfähig bleibt, wie es sich bei HTML gehört und dabei trotzdem (auf den heutigen Geräten) so etwas wie ein Tab-Widget festzuschreiben, dürfte der Quadratur des Kreises gleichkommen. Entweder es ist ein flexibles „disclosure widget“ oder es ist ein konkretes Tab-Element – beides auf einmal ist, wenn überhaupt, nur sehr schwer unter einen Hut zu bekommen.

Ein etwas zynischeres Argument gegen das HTML-Tab-Widget habe ich aber auch noch: egal wie es am Ende spezifiziert wird, es wird für 95% der Use Cases nicht reichen. Das <input type="date"> zeigt sehr schön, was passiert, wenn HTML versucht, komplexe UI-Elemente zu definieren: aus den genannten Gründen muss die Spezifikation sehr offen bleiben, was nicht nur in verschiedene Browsern zu unterschiedlichen UIs führt, sondern auch diese UIs dazu verdammt, je nach Projekt entweder zu komplex oder zu simpel zu sein. Eine Reisebuchungs-Webseite kann Datepicker gebrauchen, die zwei Monate auf einmal anzeigen können, eine Behörden-Webapp, der man sein Geburtsdatum mitteilen möchte, braucht das nicht. Beide benötigen aber sehr wohl sofortigen Cross-Browser-Support, weswegen der Griff zu einem JS-Datepicker nahe liegt. Ähnlich wird es sich beim Tab-Widget verhalten: die fette Enterprise-App braucht eine scrollbare Tab-Leiste, die zu 99% aus Whitespace bestehende Startup-Landingpage sicher nicht. All diese Details zu spezifizieren wäre zum einen eine nie dagewesene (und kaum zu bewältigende) Mammutaufgabe und würde zum anderen den Zwang zur Flexibilität unterlaufen.

Tab-Widgets sind allgegenwärtig, aber das bedeutet nicht, dass sie einfach unter einer vereinheitlichten Definition zu fassen sind. Ich glaube, dass es nicht möglich ist, ein allgemeines natives HTML-Tab-Widget vernünftig zu spezifizieren. Und sollte das möglich sein, ist immer noch fraglich, ob dieses native Widget am Ende auch genutzt wird, wenn als Alternative die in das JS-Framework der Wahl integrierte, konfigurierbare, in allen Browsern funktionierende NPM-Modul winkt.

Reinhard fragt: Conditional TypeScript-Types für Methoden-Signaturen verwenden?

Ich habe eine Frage zu Conditional Types in TypeScript. Folgendes Szenario:

class Node {…}

class Factory {
  sendMessage(type: "create" | "remove") {}
}

Die Factory soll bei sendMessage() mit "create" eine Node zurückliefern und bei "remove" eine number. D.h. ich will folgendes schreiben können:

const node = factory.sendMessage("create");
// hier soll node jetzt direkt vom typ "Node" sein

Geht das überhaupt? Ich vermute, hier wären beim Rückgabetyp von sendMessage() Conditional Types hilfreich …

Dein Ziel kannst du mit Overloads besser als mit Conditional Types erreichen. Immer wenn in der Signatur einer Funktion (oder Methode) unterschiedliche Parameter-Typen unterschiedliche Rückgabetypen produzieren sollen, ist ein Overload das Mittel der Wahl. Anders formuliert: wenn die Beziehung zwischen Input- und Output-Typen in eine Tabelle passt, sind Overloads optimal. In diesem Fall ist die Tabelle:

Parameter-Typ Return-Typ
"create" Node
"remove" number
"create" | "remove" Node | number

Der letzte Fall in der Tabelle umschreibt die eigentliche Implementierung der Funktion, während die beiden ersten Fälle die jeweiligen Spezialisierungen festlegen. In Code formuliert sieht die Tabelle wie folgt aus:

class Factory {
  sendMessage(type: "create"): Node;
  sendMessage(type: "remove"): number;
  sendMessage(type: "create" | "remove"): Node | number {
    let x: any;
    return x;
  }
}

Gegenüber Conditional Types hat Überladen einen großen Vorteil: Es ist sowohl für die TypeScript-Typinferenz als auch für Menschen leichter verständlich. Für TS ist die 1:1-Beziehung zwischen Input- und Output-Typen von Vorteil und die meisten TS-Autoren dürften mit Overloads eher vertraut sein als mit den vergleichsweise esoterischen Coditional Types.

Robert fragt: Was ist aus HTML-Imports geworden?

Was ist denn aus HTML-Imports geworden? Damit könnte man ziemlich viele Probleme erschlagen, für die man sonst zu JavaScript und Bundlern greifen muss.

Von Anfang an hat Mozilla HTML-Imports eine Absage erteilt und seither sind HTML-Imports weitgehend in der Versenkung verschwunden. ECMAScript-Module können (u.U. über Umwege) auch HTML importieren und da ES-Module definitiv existieren und HTML-Imports eigentlich nur ein anderes UI für die gleiche Funktionalität (Datei-Request durchführen und anschließend verarbeiten) darstellen, ist es durchaus vertretbar, ES-Module den Vorzug zu geben.

Außerdem unterstelle ich, dass HTML-Imports und vergleichbare Tools viel weniger nützlich sind, als manche annehmen. Vor langer Zeit habe ich zum clientseitigen Zusammenstückeln von Präsentationen eine eigene Variante von HTML-Imports gebaut, die ich zu diesem Zweck bis heute verwende. Aber auch nur zu diesem Zweck. Kein einziges anderes Projekt verlangte je nach HTML-Imports oder ähnlichem, und wenn doch, war ich stets innerhalb von JavaScript unterwegs und konnte mir mit einem Modul-Import behelfen.

Markus fragt: Top-Level-Await außerhalb von Async Functions?

Hattest du auf den JavaScript-Days nicht gesagt, dass fetch() async ist und mit await verwendet werden kann? Bei mir schlägt await fetch("/playlists") fehl und die Fehlermeldung ist: await is only valid in async functions. Heißt das, ich muss fetch() selber asynchron wrappen?

Genau richtig: await funktioniert heutzutage eigentlich nur innerhalb von asynchronen Funktionen. Und der Grund dafür ist eigentlich recht interessant. Async/Await ist eigentlich nur syntaktischer Zucker für Generator Functions bzw. das yield-Statement, das seinerseits prinzipbedingt nur in Generator Functions vorkommen kann. Dieses Video erklärt, wie mit Generators und einer kleinen Runtime die gleiche Funktionalität wie Async/Await umgesetzt werden kann und das reale Async/Await funktioniert ziemlich genau so.

Du hast also zwei Möglichkeiten das Problem anzugehen:

  1. das gute alte .then() statt await benutzen, zumindest im Top-Level außerhalb von anderen asynchronen Funktionen
  2. den ganzen Code in einen Async-Wrapper verpacken, z.B. (async () => { /* dein Code */ })()

Top-Level Await ist als offizielles JS-Feature in Arbeit, aber funktioniert weder im Browser (außerhalb der Chrome-Devtools-Konsole) noch via Babel. Es gibt experimentellen Support in Webpack und Rollup, aber von flächendeckender Einsatzbereitschaft sind wir noch etwas entfernt. Das macht aber nichts: Das Top-Level-Async-Problem besteht fast ausschließlich in Index-Modulen und CLIs-Scripts und deren Menge (und daher die Anzahl der einzubauenden Workarounds) ist in jedem Projekt endlich.

Weitere Fragen?

Habt ihr auch dringende Fragen zu Frontend-Technologien? Nur her damit! Alle Fragen in diesem Post wurden mir per E-Mail oder Twitter gestellt und ihr könnt das genau so machen! Einfach über einen der genannten Kanäle anschreiben oder für die Zeit nach Corona schon mal das komplette Erklärbären-Paket reservieren.

10 Jahre HTML5-Buch: Zeit für ein HTML5-Fazit

Veröffentlicht am 27. Mai 2020

HTML5. Webseiten innovativ und zukunftssicher

Durch groben Leichtsinn kam ich vor etwas mehr als 10 Jahren zu der Aufgabe, das erste deutschsprachige Buch zu HTML5 zu schreiben, dessen erste Auflage vor genau 10 Jahren erschien. Ein paar Exemplare der ersten Auflage lagern sogar noch im Schrank meiner Eltern, aber dort verbietet sich aufgrund von Corona zurzeit jedweder Besuch. Doch auch die mir vorliegende zweite Auflage von 2011 kann als Leitfaden durch HTML5 und vor allem die Erwartungen an HTML5 von damals mit denen von heute zu vergleichen. Hat HTML5 wie geplant die Welt erobert? Haben sich die im Buch beschriebenen Technologien durchgesetzt? Schauen wir uns doch mal an, wie sich die im Inhaltsverzeichnis geäußerten oder zumindest angedeuteten Versprechen mit Realität decken.

Das HTML von HTML5

Vernünftiges HTML hat sich derart durchgesetzt, dass kaum jemand mehr darüber spricht. Der eigentlich größte Verdienst von HTML5 war die Formalisierung des zuvor uneinheitlichen SGML-Dialekts, der sich durch Browser-Konkurrenzkampf gebildet hatte. Heutzutage sind sich (die wenigen) verbliebenen Browser-Engines über die Verarbeitung von HTML-Syntax vollkommen einig und es gibt zumindest hier keine mir bekannten Inkompatibilitäten mehr. Das ist eine große Leistung, für die sich aber niemand interessiert – HTML ist einfach Infrastruktur. Spannend ist aber, wie sehr Web Components mit HTML5 zu kämpfen haben. Custom Elements müssen so gestrickt sein, dass sie das Verhalten vorhandener HTML-Elemente und des HTML-Parsers erklären können, doch beides sind Schlangengruben voller Inkosistenzen. Diese haben sich ergeben, weil im Zuge der HTML5-Formalisierung zwischen allen inkompatiblen Browsern der kleinste gemeinsame Nenner gesucht wurde und dabei die Bedürfnisse von nutzerdefinierten Elementen keine Rolle spielte. Ob aus Web Components angesichts dieser und anderer Herausforderungen nochmal was wird?

Semantisches HTML5

Die semantischen Elemente scheinen sich im Alltags-HTML der meisten Frontendler festgesetzt zu haben und versehen die immer noch allgegenwärtigen Div-Suppen mit einer gewissen Würze. Insbesondere vor dem Hintergrund der eingebauten ARIA-Features ist das ein größerer Pluspunkt. Der Outline-Algorithmus hingegen (Gegenstand der letzten Folge von Working Draft) scheint genau so moribund wie HTML5-Microdata und alle andere Formen von Semantic-Web-Lüftschlössern. In einer Welt der Tech-Monopole finden semantische Informationen nur noch in Form des Frondienstes für den Monopol-Lord (vulgo SEO) eine ökologische Nische, sind aber weit von dem entfernt, was die diversen Vordenker (und HTML5-Buch-Autoren, die darüber viel zu viele Seiten geschrieben haben) des Semantic Web sich einst ausgemalt haben.

HTML5-Formulare

Formulare sind und bleiben ein kompliziertes Thema. An manchen Stellen finden HTML5-Neuerungen wie Date-Picker und Formular-Validierung Anwendung, doch die handgeschriebene Re-Implementierung solcher Standards ist noch sehr verbreitet. Das mag daran liegen, dass Formulare ein inhärent so ausufernd-facettenreiches Thema ist, dass kein Standard der Welt jeden Use Case bedienen kann. Und obwohl zumindest die Validierungs-API erweiterbar ist, ist sie das wohl nicht in ausreichendem Umfang. Ich würde von einem ganz klaren Teilerfolg sprechen, aus dem man die Lehre ziehen kann, dass das Extensible Web Manifesto schon den richtigeren Weg ausleuchtet, als ein Default-Datumspicker. Letzteres ist wohl eine Zu-High-Level-API, die sich nie als der Standard durchsetzen wird, denn was die Webentwickler wirklich brauchen, ist das Inputmode-Attribut, um damit eigene Formularelemente zu bauen.

Offline-Webapps

Während uns Local Storage bis zum heutigen Tag begleitet (und dank Cookie-Bannern und GDPR prominenter ist als je zuvor), müssen wir den Application Cache von HTML5 ist als kompletten Griff ins Klo betrachten. Zum Glück ist er bereits durch Progressive Web Apps zu 100% abgelöst und stellt kein Problem mehr dar. Die API des Application Cache ist/war extrem verwirrend und bei meinen HTML5-Workshops damals war nur die Drag&Drop-API von HTML5 noch schwerer zu vermitteln. Und da wir gerade beim Thema sind …

Drag & Drop

Dateien per Drag & Drop hochzuladen ist auf dem Desktop mittlerweile auch bei Web Apps gängige Praxis, was 2010 noch lange nicht der Fall war. Dieser Teil der API ist auch nicht besonders schlimm missraten, der für Drag-Operationen zwischen Webseiten sowie Webseiten und anderen Programmen umso mehr. Allerdings scheint das auch keinen Entwickler zu stören und Nutzer scheinen dieses Feature nicht zu vermissen. Ich denke, dass wir uns an dieser Stelle bei der Smartphone-Revolution bedanken dürfen. In erster Näherung sitzt kein Mensch mehr an Desktop-Computern und hantiert mit Mäusen herum, und die wenigen solchen Power-User sind offenbar damit zufrieden, Drag & Drop für ihren E-Mail-Anhang zu verwenden. Passt schon.

Video und Audio

Großes Trara um Flash-Killer und Video-Codecs, und hinterher hostet sowieso jeder seinen Content beim Plattform-Monopolisten du jour. Was kümmern die Details der Video-Player-Implementierung, wenn die tatsächliche Einbettung (sofern man sowas überhaupt noch macht) per Youtube-Iframe erfolgt? Das Web von heute ist wahrlich etwas ganz anderes als vor 10 Jahren und niemand hostet heutzutage mehr seine eigenen Videos. Podcasts werden noch individuell gehostet und per Audio-Element in Webseiten eingebunden, aber bis sich das auch erledigt hat, wird es nur eine Frage der Zeit sein.

Web Workers

Auch wenn einige wenige mit zunehmender Lautstärke für die Vorzüge von Web Workers agitieren, so haben sie sich nicht wirklich im Webentwicklungs-Alltag festgefressen. Ich schätze das liegt daran, dass der Tradeoff zwischen Einsatz und Ertrag nicht besonders attraktiv ist. Frontend-Entwickler müssen langsame, lang laufende Funktionen mühsam vom DOM und anderen Browser-APIs entkoppeln – und das setzt voraus, dass Frontend-Entwickler überhaupt mit solchen Funktionen befasst sind und nicht etwa ein wie auch immer geartetes Backend. Häufig scheint es das nicht zu geben.

Fazit und Ausblick

Was kann man über HTML5 10 Jahre später sagen und welche Lehren lassen sich ziehen? Ich würde es mal so zusammenfassen:

  • HTML ist wie geplant eine vernünftig definierte und implementierte Auszeichnungssprache geworden. Auch ohne die Konsolidierung am Browser-Markt hätten/haben wir heute eine Situation, in der jeder Browser jedes HTML exakt gleich verarbeitet.
  • Diverse Features wie Formulare und Offline-Support sind (zumindest in Teilen) an ihrer High-Level-Fehlkonzeption gescheitert. Low-Level-APIs sind das, was Webentwickler wollen, denn nur so können sie ihren Job machen und damit auch die Web-Plattform als ganzes relevant halten.
  • Semantik und Drag & Drop sind Opfer des Zeitgeists, der sich nicht mehr für Open Web interessiert und viel mehr sein Smartphone als seinen großen Desktop-Rechner nutzt.

Als die dem Ganzen zugrundeliegenden Trends würde ich zum einen Marktkonsolidierung (bei Browsern wie Plattformen) und auf der anderen Seite des Webentwicklers berechtigte Vorliebe für Low-Level-APIs ausmachen. Wenn wir mal annehmen, das sich daran nichts weiter ändert, würde ich mich auf folgende Vorhersagen für 2030 einlassen:

  • Web Assemby wird die Web-Welt auf dem Kopf stellen. Es ist schließlich die ultimative Low-Level-API, die das Web zu einer komplett technologieunabhängigen Content-Delivery-Plattform macht. Es fehlt vermutlich noch der eine oder andere Katalysator (eine CRUD-Webapp mit Rust zu schreiben erscheint mir nicht sonderlich sinnvoll), aber der wird sich schon noch einfinden. JavaScript wird noch als allgemeine Scriptsprache eingesetzt werden (ein bisschen wie Perl) und TypeScript wird einen Status wie CoffeeScript genießen.
  • Progressive Web Apps werden es schwer haben. Sie sind weniger Zu-High-Level als der Application Cache, aber sehr auf die Mobile-Plattform der Gegenwart fokussiert. Sollte sich die Plattform von ihrem heutigen Ist-Zustand wegbewegen, wird es für Webstandards schwer, sich dem anzupassen – das cross-kompilierte Web-Assemby-Produkt könnte einen Wettbewerbsvorteil haben. Außerdem mag es daran liegen, das ich seit Beginn der Corona-Pandemie keinen ICE mehr von innen erleben musste, aber könnte es nicht vielleicht doch möglich sein, das Land in endlicher Zeit so weit mit Mobilfunkmasten zuzupflastern, dass kaum jemand mehr Offline-Webapps braucht? Wäre doch ein denkbares Post-Corona-Konjunkturprogramm, nur nicht für PWA-Entwickler.
  • Unabhängiges Podcasting und damit das letzte Refugium von RSS-Feeds und Audio-Elementen ist dem Untergang durch Monopolisierung geweiht und wird in 10 Jahren keine Rolle mehr spielen. Bisher hat es noch kein Spotify o.Ä. geschafft, das Youtube für gesprochenes Audio zu werden, aber Risikokapitalgeber haben tiefe Taschen und scheinbar nichts Besseres vor.

Das mutet alles etwas apokalyptisch an, aber andererseits sind Vorhersagen nicht meine Stärke. Immerhin habe ich mal ein ganzes Buch über revolutionäre neue Web-Zukunftstechnologien geschrieben und es für sinnvoll erachtet, Seite um Seite über Microdata und Video-Codecs zu referieren. Vielleicht liege ich ja wieder komplett falsch! Wir lesen uns (spätestens) in 10 Jahren wieder und vergleichen.