Das Warum und das Wie von Event Handlern in Web Components

Veröffentlicht am 9. März 2021

Wenn ihr mir auf Twitter folgt, ist euch sicher nicht entgangen, dass ich seit einiger Zeit an einer Version 2.0 meiner Web Component <html-import> schraube. Dieses etwas seltsame Custom Element dient mir vor allem zum schnellen Zusammenstecken von Präsentationen aus Slide-Modulen, könnte aber mit wenigen Updates auch für andere Use Cases relevant werden. Also wollte ich das Element richtig gründlich aufpolieren und aus ihm die beste Web Component bauen, die ich bauen konnte. Eine gute Web Component zeichnet sich meines Erachtens vor allem dadurch aus, dass sie sich in jederlei Hinsicht wie ein eingebautes HTML-Element verhält und alle Features von nativen Elementen so gut nachbildet, wie es die APIs rund um Custom Elements zulassen. Und da mein <html-import> eine Reihe von Events wirft (importdone, importfail usw.) wollte ich auch Event Handler zum Funktionieren bringen, und zwar sowohl die DOM-Property als auch die HTML-Attribut-Variante:

<!-- das soll gehen: -->
<html-import onimportdone="window.alert('ok')"></html-import>

<!-- und das hier auch: -->

<script>
  let el = document.querySelector("html-import");
  el.onimportfail = () => window.alert("fail");
<script>

Wichtig hierbei: ein Event Handler ist nicht das Gleiche wie ein Event Listener. Event Listener werden in der Praxis per addEventListener registriert und sind vielseitig justierbar (once: true usw.), während Event Handler einfach über ein on-irgendwas benanntes HTML-Attribut oder DOM-Property auf HTML-Elemente gesteckt werden. Es kann pro Event nur einen Event Handler auf einem Element geben und dieser kann nicht groß konfiguriert werden, während pro Event auf einem Element beliebig viele unterschiedlich konfigurierte Event Listener geben kann. Technisch gesehen sind Event Handler sind eine besondere Art von Event Listener:

<!-- Event Handler -->
<button onclick="window.alert(23)"></button>

<!-- Event Listener: -->
<script>
  let el = document.querySelector("button");
  el.addEventListener("click", () => window.alert(42), { once: true });
  el.addEventListener("click", () => window.alert(1337));
<script>

Dieser Code registriert insgesamt drei Event Listener für den Button, wovon einer per Event Handler definiert wurde.

Die Logik für Event Handler ist (leicht überraschend) keine Magie. Mit on beginnende Properties und Attribute werden nicht automagisch zu Andockstellen für Event Handler, sondern für jedes Event müssen Properties und Attribute von Hand eingebaut werden. Und wie sich herausstellen sollte, ist dieses Einbauen kein Selbstläufer.

Warum überhaupt Event Handler?

Event Handler genießen einen zweifelhaften Ruf. Zum Einen gibt es sie nur für einen Teil der eingebauten Events, zum Anderen kann es pro Event nur einen einzigen, nicht-konfigurierbaren Handler geben (anders als bei addEventListener). Hinzu kommt, dass die HTML-Attribut-API richtiggehend gefährlich ist – im Prinzip bietet sie ein deklaratives eval()! Eine generelle Hände-Weg-Policy, wie von MDN verordnet, hat also durchaus ihre Berechtigung, gerade in Anbetracht des Vorhandenseins von addEventListener als Alternative. Ich habe als Erwiderung nur ein schwächliches und ein ganz besonders schwaches Argument vorzubringen:

  • Nicht ganz so schwach finde ich den Hinweis, dass eine schnelle, dreckige onevent-Property bei Tests und Prototypen schon ganz praktisch sein kann. Ich als fleißiger (fast ausschließlicher) Test- und Prototyp-Autor bin diesbezüglich im Vergleich zu richtigen Entwicklern etwas voreingenommen, aber es ist nun mal so: Ich schreibe gerne mal ein onclick, solange der Code nicht Gefahr läuft, in irgendeiner Form von Produktiveinsatz zu landen.
  • Als schwächstes, aber mir wichtiges Argument bleibt außerdem, dass meine Prämisse immer noch lautet: eine gute Web Component verhält sich wie ein natives HTML-Element und das schließt nun mal funktionierende Event Handler ein. Wenn selbst vergleichsweise neue native HTML-Elemente wie <video> z.B. onplay als Property und HTML-Attribut unterstützen, dann sollte das auch mein <html-import> können. Bzgl. eval()-Gefahr kann ich mich ja einfach darauf verlassen, dass die Nutzer:innen der Komponente schlau genug sein werden, keinen Unfug zu bauen, denn sie schaffen das schließlich auch mit allen anderen Events auf allen anderen Elementen.

Da Minus mal Minus Plus ergibt, würde ich sagen, dass zwei schlechte Argumente in Kombination zumindest mal gut genug sind, um sich an der Implementierung von Event Handlern zu versuchen. Wie schwer kann's schon sein?

Wie Event Handler funktionieren

Wenn wir die Spezifikationen zu Events lesen, stellen wir fest: Event Handler sind gar nicht mal so trivial! Abgesehen von allerlei für uns irrelevanten Sonderfällen rund um Namespaces, Frames und Window-Objekte lässt sich die Spec auf das folgende Regelwerk eindampfen:

  1. Event Handler für ein Event foo können per HTML-Attribut oder DOM-Property angegeben werden. Im ersten Fall muss der Wert des Attributes ein String aus auführbarem JavaScript sein, im zweiten Fall muss es eine JavaScript-Funktion sein.
  2. HTML-Attribut und DOM-Property sind zwei Wege, den einen Handler für das eine Event auf dem Ziel-Element zu definieren. Der zuletzt gesetzte Event Handler (egal ob via Attribut oder via Property) ist der, der gilt.
  3. Auslesen der DOM-Property liefert den zum Zeitpunkt des Auslesens aktuellen Event Handler. Das ist, wenn zuletzt die DOM-Property gesetzt wurde, die gesetzte Funktion. Wenn zuletzt das HTML-Attribut gesetzt wurde, liefert das Auslesen der DOM-Property eine Funktion, die den JavaScript-String im Attribut-Wert wrappt und ausführt (d.h. quasi ein eval()) macht. Gibt es keinen aktuellen Event Handler, liefert das Auslesen der DOM-Property null.
  4. Wird die DOM-Property auf null gesetzt, wird der aktuelle Event Handler deaktiviert was gleichbedeutend mit keinem aktiven Event Handler ist. Wichtig hierbei: ein eventuell vorhandenes HTML-Attribut bleibt, wie es ist und wird nicht geändert, wenn sich die dazugehörige DOM-Property ändern (anders als z.B. das Attribut-Property-Zusammenspiel von id und class/className funktioniert)
  5. Das Entfernen eines vorher vorhandenen HTML-Handler-Attributs deaktiviert den dazugehörigen Event Handler ebenfalls, d.h. die DOM-Property liefert null. Ein nicht vorhandenes Handler-Attribut zu entfernen hat keinen Effekt. Attribut-Werte wie der leere String oder "null" deaktivieren den Handler ebenfalls nicht, sondern führen nur zu weitgehend funktionslosen Handler-Funktionen (ein eval("null") macht schließlich nichts Spannendes).
  6. Wird die DOM-Property auf null gesetzt, wird der aktuelle Event Handler deaktiviert was gleichbedeutend mit keinem aktiven Event Handler ist. Wichtig hierbei: ein eventuell vorhandenes HTML-Attribut bleibt wie es ist und wird nicht geändert, wenn sich die dazugehörige DOM-Property ändern (anders als z.B. das Attribut-Property-Zusammenspiel von id und class/className funktioniert)
  7. Wird ein vorhandener Handler ersetzt (egal ob per Attribut oder Property) übernimmt er in der Reihenfolge der ausgeführten Event-Callbacks die Position seines Vorgängers. Definieren wir einen Handler A, dann einen Listener B und ersetzen dann Handler A durch Handler C, ist die resultierende Ausführungsreihenfolge C-B, denn C übernimmt in der Event-Reihenfolge den Platz von A

Alles in allem sollte unser selbstgebautes Event-Handler-Handling auf Custom Elements genau so funktionieren, wie es onclick im folgenden Code macht:

const foo = document.querySelector("foo");

foo.addEventListener("click", () => window.alert("Listener 1") ); // Normaler Listener

foo.setAttribute("onclick", "window.alert('Attribut 1')") // Handler via Attribut

foo.getAttribute("onclick") // > "window.alert('Attribut 1')"

foo.onclick // > ƒunction onclick(event) { window.alert('Attribut 1') }

// Klick jetzt liefert: "Listener 1", "Attribut 1"

foo.addEventListener("click", () => window.alert("Listener 2") )

// Klick jetzt liefert: "Listener 1", "Attribut 1", "Listener 2"

foo.setAttribute("onclick", "window.alert('Attribut 2')") // Update des vorhandenen Handlers

// Klick jetzt liefert: "Listener 1", "Attribut 2", "Listener 2"

foo.onclick = () => window.alert("Property 1") // Update des vorhandenen Handlers

// Klick jetzt liefert: "Listener 1", "Property 1", "Listener 2"

foo.onclick = null // Handler dekativieren

// Klick jetzt liefert: "Listener 1", "Listener 2"

foo.onclick = () => window.alert("Property 2") // Handler wiederbeleben

// Klick jetzt liefert: "Listener 1", "Listener 2", "Property 2"

foo.removeAttribute("onclick") // Handler wieder deaktivieren

// Klick jetzt liefert: "Listener 1", "Listener 2"

Zusammengefasst:

  1. Event Handler stellen einen „Slot“ für einen Event Listener bereit, der per Attribut oder Property befüllt werden kann
  2. So registrierte Event Listener reihen sich in die Ausführung aller anderen Event Listener ein
  3. Das Update eines Event Handler übernimmt in der Ausführungsreihenfolge aller Event Listener den Platz seines Vorgängers
  4. Die Kopplung zwischen Property und Attribut ist unvollständig; Attribute-Updates ändern die Property mit, umgekehrt gilt das nicht

Dieses Verhalten ist für sich genommen nicht so unglaublich schwer richtig hinzubekommen, aber es ist dann doch ein gewisser Aufwand, der für jedes Event anfällt. Aus der Perspektive einer Web Component ist vor allem relevant, dass wir auf Updates der Handler-Attribute reagieren müssen. Und da ich keine Lust habe, all das bei jedem Event auf jeder Komponente zu berücksichtigen, habe ich ein kleines Mixin gebaut, mit dem sich das Verhalten von onclick für jedes beliebige Event von Custom Elements nachbilden lässt.

Event Handler für alle: OnEventMixin

OnEventMixin hackt mithilfe von gutem alten Prototype-Patching alles für Event Handler in vorhandene Custom-Element-Klassen hinein! Die Benutzung könnte einfacher nicht sein:

<script type="module">
  import OnEventMixin from "./oneventmixin.js";

  class MyFoo extends HTMLElement {
    // Triggert das DIY-Event "stuffhappens", aber es entsteht nicht
    // automatisch eine API für onfoo Event Handler
    connectedCallback() {
      this.addEventListener("click", () => {
        this.dispatchEvent(new Event("stuffhappens"));
      });
    }
  }

  // OnEventMixin fügt onstuffhappens zu MyFoo hinzu
  window.customElements.define("my-foo", OnEventMixin(MyFoo, ["stuffhappens"]));
</script>

<my-foo onstuffhappens="window.alert('Funktioniert')">
  Klick triggert stuffhappens-Event (inkl. Alert)
</my-foo>

Neben den eher grusligen HTML-Attributen funktionieren dank des Mixins auch die entsprechenden DOM-Properties mit gleichen Namen.

OnEventMixin gibt's auf Github und auf NPM:

$ npm -i @sirpepe/oneventmixin

Die Readme erklärt alles Wissenswerte, aber im Endeffekt stopfen wir einfach unsere Komponenten-Klasse und eine Liste von Events in die Funktion OnEventMixin hinein – mehr ist nicht zu tun. So einfach die Benutzung des Mixins ist, so ist die Implementierung doch eher etwas für die Freunde von schwererer JavaScript-Kriminalität. Der Mixin modifiziert immerhin die Klasse auf die folgende Weise:

  1. Pro Event wird ein Getter/Setter-Paar für die on-DOM-Property in die Zielklasse gehackt. Diese Getter und Setter initialisieren bei Erstbenutzung ein Objekt, das den eigentlichen Wert des Event Handlers verwaltet (v.a. die Reihenfolge). Dieses Objekt wird auf dem Ziel-Element hinter einem Symbol gespeichert und von den Gettern und Settern angesteuert.
  2. Wenn die Zielklasse static get observedAttributes() implementiert, wird die Liste der Attribute um die Handler-Attribute ergänzt, ansonsten wird observedAttributes erstmalig mit den Handler-Attributen in die Klasse gebastelt.
  3. Ein ggf. vorhandener attributeChangedCallback() wird dergestalt ergänzt, dass Updates der Handler-Attribute den zum Event passenden Event-Manager ansteuern. Attribute, die in den vorherigen observedAttributes gelistet waren, werden an den ursprünglichen attributeChangedCallback() weitergeleitet, aber auch nur diese! Der Event-Manager wird bei Bedarf erst im attributeChangedCallback() initialisiert und falls es noch keinen attributeChangedCallback() gibt, definiert der Mixin ihn erstmals.

Obwohl die Zielklasse an diversen Stellen modifiziert wird, arbeitet der Mixin fast perfekt minimalinvasiv: Die zusätzlichen on-Properties wurden explizit bestellt und der neue attributeChangedCallback() delegiert nur für vorher schon zu observierende Attribute an den originalen attributeChangedCallback(). Allein der Umstand, dass die observedAttributes ersetzt werden, könnte in Production als unerwünschte Nebenwirkung spürbar werden – dort tauchen notgedrungenermaßen die zusätzlichen Event-Handler-Attribute auf. Umgehen lässt sich das nicht wirklich:

  1. Der Mixin könnte, statt den Prototype zu patchen, eine Wrapper-Klasse konstruieren und hierüber die zusätzliche Funktionalität einspeisen. Problematisch wäre hieran, dass die Originalklasse und die Wrapper-Klasse dann nicht mehr das gleiche Objekt wären, womit Entwickler:innen leicht durcheinander kommen könnten – zumal das das observedAttributes-Phänomen auch nicht wirklich lösen würde.
  2. Das Verwirr-Risiko wäre gebannt, wenn der Mixin nicht als Mixin-Funktion, sondern als Wrapper-API um window.customElements.define() daherkäme; die Klasse würde erst in dem Moment einen Wrapper erhalten, in dem sie registriert wird. Damit wäre es aber auch unmöglich, Event-Handler-Funktionalität in Subklassen zu erben, da diese nur im Wrapper implementiert und dieser mit der define()-API nicht mehr zugänglich wäre.
  3. Das Management der Attribute in einem MutationObserver zu verlegen fällt aus, da Observer im Gegensatz zum attributeChangedCallback() asynchron sind. Das originale Verhalten der Event-Handler-Attribute ist damit nicht nachzubilden.
  4. Eine Umsetzung per Decorator würde nur etwas an der API ändern und kommt mir persönlich, solange es hierfür keinen beschlossenen ECMAScript-Standard gibt, ohnehin nicht in die Tüte.

Unterm Strich sollten sich die Änderungen an den observedAttributes unter fast allen Umständen verkraften lassen, denn darauf zugreifen wollen wir praktisch nie – es geht eigentlich immer allein um die Steuerung des attributeChangedCallback(). Am ehesten fährt der Mixin noch TypeScript-Nutzern in die Parade, denn TS kommt mit Klassen-Patching nicht wirklich klar und benötigt einen kleinen Workaround um die Mixin-Auswirkungen in saubere Klassen-Typen zu überführen.

Limitierungen von selbstdefinierten Event Handlern

Jenseits aller Mixin-Nebebenwirkungen haben selbstdefinierten Event Handler aber eine handfeste Limitierung: sie sind nicht auf allen Elementen verfügbar, nur auf Custom Elements. Alle eingebauten Event Handler sind im HTMLElement-Interface definiert und funktionieren daher allesamt auf allen HTML-Elementen. Auch Elemente, die ein Event A niemals feuern würden, können einen Event Handler für Event A haben. Das ist für Event Bubbling durchaus nützlich:

<!-- In Aktion: codepen.io/SirPepe/pen/MWbrWma -->
<div onchange="window.alert('Input geändert!')">
  <input type="text" value="Änder mich!">
</div>

Unsere selbstdefinierten Event Handler können das so ohne weiteres nicht. Zwar wäre es möglich, onfoo-DOM-APIs in HTMLElement hineinzupatchen, aber die Unterstützung der dazugehörigen HTML-Attribute ist nicht möglich. Diese Attribute müssten synchron überwacht werden, was aber nur mit attributeChangedCallback() in Custom Elements geht – MutationObserver sind, da asynchron, kein brauchbarer Ersatz. Somit bleiben unsere DIY-Handler auf den Einsatz in Custom Elements beschränkt, können dort aber durchaus mit Bubbling eingesetzt werden:

<script defer>
  customElements.define(
    "triggers-foo",
    OnEventMixin(class extends HTMLElement {}, ["foo"])
  );
  customElements.define(
    "receives-foo",
    OnEventMixin(class extends HTMLElement {}, ["foo"])
  );
  
  document.querySelector("triggers-foo").dispatchEvent(
    new Event("foo", { bubbles: true })
  );
</script>

<receives-foo onfoo="window.alert('Foo im Kindelement')">
  <triggers-foo></triggers-foo>
</receives-foo>

Umgekehrt gilt die Einschränkung natürlich nicht. Custom Elements erben (und sei es über Umwege) von HTMLElement und können daher Event Handler die Standard-Events haben, die dann auch für bubblende Events aus den eigenen Kindelementen greifen.

Fazit

Wie wir gesehen haben, sind Event Handler limitiert, gefährlich und in ihrer Implementierung nicht trivial. Wollen wir solche Features in unseren Web Components haben? Ich denke, dass Event Handler neben den schon genannten noch zwei weitere Eigenschaften haben, die uns in dieser Frage der Antwort „Ja“ etwas näher bringen können. Zum Einen sind Event Handler ein etabliertes HTML/DOM-Feature und wenn wir meiner Eingangsprämisse folgen, nach der eine gute Web Component ein sich nahtlos in das vorhandene HTML-Vokabular integrierendes Element ist, gehören Event Hander einfach dazu. Zum Anderen sind Event Handler, wie der Mixin zeigt, zwar kompliziert, aber generalisierbar. Alles, was wir tun müssen, um N Events in Event Handlern zu unterstützen, ist unsere Komponentenklasse einmal durch OnEventMixin zu schieben. Dadurch (und die Tatsache, dass der Mixin sehr klein ist) stellt sich eher die Frage: „Warum würden wir dieses Feature nicht haben wollen?“

Für <html-import> gibt es jetzt jedenfalls Unterstützung für die Event Handler onstart, ondone, onfail und onabort, ganz so, wie es die HTML-Götter wollen würden. Vorausgesetzt, sie würden ein Element wie <html-import> für eine gute Idee halten.

Array Ex Machina

Veröffentlicht am 26. Januar 2021

Es heißt, JavaScript-Entwickler hätten tausend Begriffe für „undefined is not a function“, doch was es in JS wirklich tausendfach gibt, sind Wege zur Array-Initialisierung. Manche dieser Wege sind sinnvoll, manche sind weniger sinnvoll und die meisten Wege bieten interessante Tradeoffs. Das gilt selbst für die vermeintlich einfachste Array-Erstellungs-Variante, das Literal:

let myArray = [];

Das erscheint für das ungeübte Auge wie eine okaye Möglichkeit, ein Array der Länge 0 zu erschaffen, aber dieses Pattern ist nicht immer das Optimalste! Denn zumindest clientseitiges JavaScript will minifiziert werden und ein leeres Array-Literal wird nie kürzer werden als zwei Zeichen (Klammer auf, Klammer zu). Projekte, die Wert darauf legen, jedes Bit einzusparen, definieren deshalb gerne in einem Extra-Modul eine Konstante für das leere Array:

// https://github.com/preactjs/preact/blob/master/src/constants.js
export const EMPTY_ARR = [];

Die Idee dahinter: aus EMPTY_ARR wird im Rahmen der Minifizierung (hoffentlich) eine Ein-Buchstaben-Variable wie x, die bei genauer Betrachtung aus weniger Zeichen besteht als ein handgeschriebenes Array-Literal!

// Vor Minifizierung und Bundleing
import { EMPTY_ARR } from "./constants";
doStuff(EMPTY_ARR);

// Nach Minifizierung und Bundleing
const b=[];a(b)

Werden leere Arrays oft genug verwendet, amortisieren sich irgendwann die Extra-Bytes für die Deklaration der Leeres-Array-Variable. Wir sollten nur aufpassen, dieses Array nicht versehentlich mit Inhalt zu versehen oder auf andere Weise zu modifizieren, denn das resultierende Massaker zu debuggen dürfte interessant werden.

Wenn wir dieses Risiko nicht eingehen, aber dafür unseren inneren OOP-Tiger erwecken wollen, können wir natürlich auch mit new Array() arbeiten:

let myAbstractArrayFactorySingleton = new Array();
myAbstractArrayFactorySingleton.push("ok boomer");

Diese Variante ist eigentlich nur zu empfehlen, wenn wir statt nach Arbeitsstunden oder sonstigen Phantasiemetriken nach produzierten Code-Kilobytes bezahlt werden. In einem solchen Arrangement haben wir nicht nur die Chance, uns als Held der Arbeit zu profilieren, nein; der Array-Constructor bietet darüber hinaus als Extra-Feature an, das Array mit einer vorgegebenen Länge zu initialisieren:

let myArray = new Array(5);
// Array mit length = 5

Spannend hierbei ist, dass das resultierende Array ein Array mit einer length von 5 ist, aber, es aber mitnichten 5 Felder hat! Historisch waren JavaScript-Arrays in erster Linie Lügengebäude und erst im Nebenjob Datenstrukturen. Sie waren ganz normale JavaScript-Objekte, die ein paar Extra-Regeln folgten:

  1. Keys sind numerische Strings
  2. Es existiert ein Extra-Key namens length, dessen Wert der Key mit dem höchsten numerischen Wert + 1 ist

Und wenn wir ehrlich sind, was braucht der Mensch bzw. das Array mehr als das? Wenn wir ein Fake-Array aus einem Objekt und den obigen Regeln bauen, können wir das Lügengebäude problemlos in z.B. For-Schleifen nutzen:

let fake = {
  "0": 23,
  "1": 42,
  "length": 2,
};

for (let i = 0; i < fake.length; i++) {
  console.log(fake[i]); // läuft!
}

Einer der zahlreichen Clous an new Array(x) ist, dass es ein Array anlegt, das zwar eine length von x hat, aber nicht die entsprechenden Felder. Die length an sich ist eine Lüge:

let a = [1, 2, 3]; // Array mit drei Mal echtem Inhalt
console.log(Object.keys(a), a.length) // ["0", "1", "2"], 3

let b = new Array(3); // Array mit Lügen-Length
console.log(Object.keys(b), b.length) // [], 3

Deshalb steht, wenn wir new Array(3) in die JS-Konsole von z.B. Chrome werfen, im Output [empty × 3], was bemerkenswert ist – immerhin ist „empty“ in JavaScript weder Typ noch Wert noch Konzept. Aber anders weiß der Browser ein Flunker-Array nicht zu umschreiben. Ganz allgemein können wir über length sagen, dass es meist irgendwie mit dem Array-Inhalt korreliert, aber etwas Sicheres sagt es nicht aus:

let a = [];
// a.length = 0
// Browser-Repräsentation von a ist []

a[7] = 42;
// a.length = 8
// Browser-Repräsentation von a ist [empty × 7, 42]

a.length = 10;
// a.length = 10
// Browser-Repräsentation von a ist [empty × 7, 42, empty × 2]

Die Clowns unter euch fragen sich vielleicht bereits, was passiert, wenn wir new Array() mit einer Kommazahl füttern:

new Array(23.42); // Höhö

Das Ergebnis: ein Uncaught RangeError mit Verweis auf eine angebliche „Invalid Array Length“. Anscheinend mag der Array-Constructor nur ganzzahlige Inputs haben. Aber das können wir in modernem JavaScript problemlos sicherstellen, denn für ganzzahlige Werte gibt es ja seit einiger Zeit den BigInt-Typ. Damit kann nichts mehr schiefgehen, richtig?

new Array(23n); // Garantiert ganzzahlig!

Das Ergebnis: ein Array der Länge 1 mit 23n als Inhalt. Es stellt sich nämlich raus, dass new Array() als Length-Input zwar unbedingt eine ganze Zahl haben möchte, die aber auch nicht zu ganzzahlig (also ein BigInt) sein darf. Genau genommen ist das Regelwerk von new Array() wie folgt:

  1. Wenn es nur einen Input gibt und dieser eine ganzzahlige Nicht-BigInt-Zahl ist, wird ein Array mit entsprechender Länge, aber ohne Felder oder Inhalt erzeugt
  2. Bei zwei oder mehr Inputs oder bei einem Input, der weder eine ganzzahlige Nicht-BigInt-Zahl noch eine nicht-ganzzahlige Nicht-BigInt-Zahl ist, wird ein Array mit dem Input als Inhalt (und passender Länge) erzeugt.
  3. Bei einem Input, der eine nicht-ganzzahlige Nicht-BigInt-Zahl ist, setzt es einen ReferenceError.

Alles klar? Schreibt mir auf Twitter, wie viele der folgenden Ergebnisse ihr korrekt vorhergesagt habt:

let a = new Array(4e2);
let b = new Array(NaN);
let c = new Array(new Number(42));
let d = new Array(...[5]);
let e = new Array(...new Array(...new Array(5)));
let f = new Array(...new Array(new Number(42n)));

Dieses Verhalten ist nicht nur absolut trivial und für jeden JS-Nerd komplett offensichtlich, sondern auch von TypeScript-Typechecker abgesegnet und daher über jede Kritik erhaben. Doch aus nicht nachvollziehbaren Gründen beschloss die ECMAScript-Arbeitsgruppe, diese wunderschöne API um eine abgespeckte Variante zu ergänzen, die nur einen Teil der Aufgaben von new Array() zu erfüllen vermag. Array.of() ist wie new Array(), nur langweiliger:

let a = Array.of(3);             // [3]
let b = Array.of(3, 5);          // [3, 5]
let b = Array.of(NaN, Infinity); // [NaN, Infinity]
let c = Array.of(42n);           // [42n]

Da könnten wir auch gleich Array-Literale schreiben (wenngleich das, wie schon besprochen, den Eindruck erwecken könnte, dass uns am Ende die Bundle-Größe nicht wichtig sei). Einen echten ECMAScript2077-Ersatz für new Array() mit einem numerischen ganzzahligen Nicht-BigInt-Input gibt es nicht direkt, aber per Manipulation der Array-Length können wir den gleichen Effekt erzeugen:

let a = []
a.length = 7;

// Gleicher Effekt:
let b = new Array(7);

Obwohl es bei a wie b um die Manipulation der length geht, bietet die a-Variante mehr Freiheiten, denn hier herrschen die für JavaScriptler (und nur für JavaScriptler) etwas weniger überraschenden Typ-Konvertier-Regeln:

let a = []

a.length = 3;
// Klappt, length ist 3

a.length = "5";
// Klappt, length ist 5

a.length = new Number(7);
// Klappt, length ist 7

a.length = null;
// Klappt, length ist 0

a.length = { toString: () => "2e4" };
// Klappt, length ist 20000

a.length = 23n
// Uncaught TypeError: Cannot convert a BigInt value to a number

Es erscheint vielleicht etwas seltsam, dass null akzeptiert und zu 0 verwandelt wird, während 23n zum Fehler führt, aber BigInt ist tatsächlich in der Lage, Zahlen zu repräsentieren, die nicht als JS-Number abbildbar sind. Die Umwandlung von null oder "5" in Zahlen ist zwar auch ein bisschen seltsam, aber zumindest innerhalb von JavaScript etablierte Praxis und insofern etwas weniger wirr, als das Regelwerk rund um new Array().

Ganz am Rande: der einfachste Weg, ein Array zu leeren, besteht darin, length auf 0 zu setzen, wobei natürlich auch jede der folgenden Alternativ-Varianten zum Erfolg führt:

let a = [ 1, 2, 3]

// In langweilig:
a.length = 0;

// Viel besser:
a.length = "";
a.length = null;
a.length = "00000000000000";
a.length = { toString: () => 0 }

Aber ganz gleich, wie wir zu einem Array mit Inhalt/Length-Diskrepanz kommen: wie können wir diesem Array zu Inhalt verhelfen? Eine Möglichkeit ist die fill()-Methode:

new Array(7).fill(42, 0, 7)
// > [42, 42, 42, 42, 42, 42, 42]

Wichtig ist hierbei, dass fill(), das in diesem Beispiel an allen Indizes von 0 bis 7 den Wert 42 einsetzt, sich nur an der length seines Ziels orientiert. Würden wir new Array() mit 4 füttern, würden auch nur die Felder 0 bis 3 mit 42 befüllt, denn die Felder 4 bis 6 existieren zwar ebenso wenig wie wie 0 bis 3 (wir erinnern uns), aber die Existenz von 0 bis 3 wird wenigstens von der length behauptet, und das ist alles was fill() braucht:

// Keine Felder, length = 7, 0-7 befüllt
new Array(7).fill(42, 0, 7); // [ 7 × 42 ]

// Keine Felder, length = 7, 0-7 befüllt
new Array(4).fill(42, 0, 7); // nur [ 4 × 42 ] da length = 4

Aus diesem Grund funktioniert fill() auch ganz hervorragend mit Nicht- bzw. Fake-Arrays mit einer Fake-Length:

// Kein Array mit einer Length, die mit nichts korrespondiert
let fake = { length: 7 };

// fill() für's Fake ausleihen
[].fill.call(fake, 42, 0, 7);

// fake = { 0: 42, 1: 42, 2: 42, 3: 42, 4: 42, 5: 42, 6: 42, length: 7 }

Das vielseitige fill() hat nur einen Haken: es befüllt Arrays (und alles, was ausreichend array-ähnlich ist, also eine length hat) mit einem einzigen, festen Wert. Falls es für jeden Index ein eigener Wert sein soll, hilft Array.from():

Array.from({ length: 7 }, (_, i) => i);
// > [ 0, 1, 2, 3, 4, 5, 6 ]

Array.from() macht aus seinem Input ein Array indem entweder der Iterator-Mechanismus des Inputs angestoßen wird (falls vorhanden) oder es als Fake-Array (mit length als maßgeblichem Merkmal) behandelt wird. Da Array.from() außerdem eine optionale Map-Funktion akzeptiert, ist es ein Leichtes, den Inhalt des Output-Arrays zu gestalten. Im obigen Beispiel verwenden wir als Inhalt den Index der (nicht-existenten) „Werte“ des Input-Fake-Arrays und schon haben wir eine mit 0 beginnende Zahlensequenz geschaffen! Mit minimal mehr Code können wir uns Arrays mit beliebigen Sequenzen erzeugen lassen:

const sequence = (from, to) => {
  return Array.from({ length: 1 + to - from }, (_, i) => i + from);
};
let numbers = sequence(5, 9);
// > [ 5, 6, 7, 8, 9 ]

Der einzige Makel der sequence()-Funktion ist, dass sie nur endliche Sequenzen erzeugen kann, da sie direkt ein fertig befülltes Array liefert und Computer (Stand 2021) immer noch die unangenehme Eigenschaft haben, über endliche Mengen an Speicher zu verfügen. Generator Functions können da helfen:

const range = function* (from, to) {
  while (from <= to) {
    yield from++;
  }
};

Der von range() erzeugte Generator kann entweder bei Bedarf stückweise Zahlen abrufen oder in Array.from() gesteckt und in ein Array verwandelt werden. Letzteres setzt natürlich voraus, dass die Sequenz endlich ist:

// Unendliche Zahlenreihe
const infinite = range(0, Infinity);

// Kein Problem
setTimeout(function loop () {
  console.log(infinite.next().value);
  setTimeout(loop, 1000);
}, 1000);

// Tilt!
const allNumbersEver = Array.from(infinite);
console.log(allNumbersEver)

Leider ist Array.from() von sich so überzeugt, dass es neben Arrays, array-ähnlichen Objekten und allen Objekten mit Iterator-Implementierung auch alles zu konsumieren versucht, das endlos ist – und sich dabei natürlich hoffnungslos überfrisst. Aber das lässt sich nicht verhindern; die Unendlichkeit einer unendlichen Sequenz ist nicht vor dem Abruf unendlich vieler Einträge aus der Sequenz absehbar. Die unendliche Sequenz ist ein Sonderfall, bei dem Array.from() auch nicht zur Array-Initialisierung taugt, wobei das Problem eher ist, dass Arrays an sich nicht gut zur Unendlichkeit passen.

Zusammengefasst können wir sagen, dass es eine Menge Möglichkeiten gibt, in JavaScript Arrays heraufzubeschwören. Das Array-Literal ist der einfachste Weg und durch eine globale Variable können wir unsere Bundle-Größe minimal heruntergolfen. Ergänzend bietet Array.of() die Funktionalität eines Array Literals in Form einer Funktion an. Array-Lengths können lügen und eine Belegung von Feldern vorgeben, die überhaupt nicht vorhanden ist. Da aber auch lügende Arrays nützlich sein können (z.B. für die Weiterverarbeitung durch fill()), können wir sie erschaffen, indem wir bei einem leeren Array die length auf den Zielwert setzen. Falls wir damit leben können, dass ein Array tatsächlich bei Initialisierung die Felder hat, die die length behauptet, ist Array.from({ length: x }) (mit der Ziel-Anzahl der Felder für x) das Mittel der Wahl. Da Array.from({ length: x }) als zweiten Parameter eine Mapping-Funktion akzeptiert, können wir dem generierten Array pro Feld variablen Inhalt verpassen, wohingegen fill() nur einen statischen Wert in einen gegebenen Bereich hineinschreibt. On-Demand-Erzeugung von Sequenzen ist das Fachgebiet von Generator Functions, deren Resultate sich per Array.from() (sofern endlich) in Arrays überführen lassen.

Das Wichtigste ist: in jedem Fall können wir von new Array() die Finger lassen. Denn das kann zwar (vom On-Demand-Aspekt abgesehen) so gut wie alles leisten, was wir in diesem Artikel besprochen haben, aber bietet all seine Fähigkeiten auf die jeweils unbequemste und fallenstellerischste Weise an. Außerhalb von Witzen über und Quizfragen zu JavaScript gibt es Stand 2021 keine Use Cases für new Array().

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 …