Ornament - Eine Web-Component-Microlibrary

Veröffentlicht am 15. November 2023

Die Krux mit der Komplexität moderner Webanwendungen ist, dass die Vergangenheit vorbei ist und wir das Rad der Zeit nicht zurückdrehen können. Es ist nicht sinnvoll, einfach React, Vue, Angular und Co. komplett links liegenzulassen und wieder Webapps mit jQuery bauen, denn obwohl jQuery selbst noch genau wie früher funktioniert, funktioniert die Welt nicht mehr wie früher. Es arbeiten größere Teams an größeren Projekten mit größerer Komplexität und vor allem ist jQuery auf eine ganz bestimmte Weise überflüssig geworden. In jQuerys Selbstbeschreibung heißt es:

“[jQuery] makes things like HTML document traversal and manipulation, event handling, animation, and Ajax much simpler with an easy-to-use API that works across a multitude of browsers.”

Das Problem ist: Die easy-to-use API löst kein akutes Problem mehr! Entwickler:innen schweben heute entweder mit Frontend-Frameworks auf All-Inclusive-Abstraktionsebenen, oder sie schreiben ganz einfach Vanilla JS. DOM-Traversal, Fetch-API sowie CSS- und JavaScript-Animationen gibt es nativ in jedem Browser und auch das Problem der Cross-Browser-Kompatibilität ist längst nicht mehr in dem Maße existent, wie es in den frühen 2000ern der Fall war. Die Plattform bietet mittlerweile selbst alle Features, die früher von jQuery, Mootools und ähnlichen Lückenfüller-Libraries geschlossen werden mussten.

Das Web-Component-Problem

Das trifft jedenfalls zu, wenn es um die normale DOM-Programmierung angeht. Im Reich der Web Components sind die nativen APIs sowohl konzeptionell knifflig als auch unvollständig. Web Components mit Vanilla JS zu entwickeln ist fast genauso lästig, wie anno 2004 JS ohne jQuery zu schreiben! Eine kleine Auswahl an Ärgernissen gefällig?

  • Der Element-Upgrade-Prozess ist ab Werk fehleranfällig und die daraus resultierenden Probleme sind schwierig zu diagnostizieren.
  • Lifecycle-Callbacks wie connectedCallback() und adoptedCallback() sorgen dafür, dass Funktionalität über viele Klassenmethoden verstreut wird. Aufrufe von Update-Methoden müssen ggf. in viele verschiedene Callbacks eingebaut werden, was unübersichtlich ist und die Wartung erschwert.
  • Nur der attributeChangedCallback() hat das genau gegenteilige Problem: Das Handling aller Attribute ist in ihm zusammengefasst, was ihn lang und kompliziert werden lässt. Andererseits feuert er nur für Attribute, die in den observableAttributes vorgemerkt wurden, deren Update leicht vergessen werden kann.
  • Attribut-Handhabung ist ganz allgemein entsetzlich aufwendig. Ein gutes Attribut auf einem HTML-Element hat eine JavaScript-API und ein HTML-Attribut, und beide Parts müssen sauber synchronisiert sein. Dazu müssen Entwickler:innen einen privaten State und ein Getter-Setter-Paar und Attribut-Handling via attributeChangedCallback()/observedAttributes ausprogrammieren, was schon in simplen Fällen in Spaghetticode ausartet.
  • Jenseits der Komplexität fehlt es beim Attribut-Handling an eingebauten Primitives. Obwohl in der HTML-Plattform konzeptionell Dinge wie „String-Attribut“ und „Boolean-Attribut“ vorhanden sind (in Form von u. a. id und disabled), können wir bei Web Component auf diese in den Browser-Interna schon vorhandene Logik nicht zurückgreifen. Stattdessen dürfen wir uns selbst überlegen, wie genau wir Attribut-Strings in andere Datentypen parsen – für jedes Attribut von vorn.

All diese Punkte stellt keine Kritik an der grundsätzlichen Philosophie von Web Components und ihrer APIs dar. Es ist in Ordnung, dass Webentwickler:innen ihre eigenen HTML-Elemente mit einer JavaScript-Klasse definieren können. Imperative Programmierung und OOP (in geringer Dosierung) sind vertretbare Techniken der Software-Entwicklung. Die aktuellen APIs sind nur sehr, SEHR umständlich und sehr, SEHR lückenhaft – so umständlich und lückenhaft, dass sie der Entwicklung der eigentlichen Komponenten im Wege stehen. Selbst die simpelste Komponente resultiert, wenn mit Bordmitteln geschrieben, in komplett inakzeptablem Spaghetticode:

class CalculatorWidget extends HTMLElement {  
  // Private Zustände "a" und "b" mit Initialisierung aus den HTML-Attributen
  #a = Number(this.getAttribute("a"));
  #b = Number(this.getAttribute("b"));
  
  // Öffentliche Get-API für "a"
  get a() {
    return this.#a;
  }
  
  // Öffentliche Set-API für "a". Typchecks, Attribut-Update und Render-Aufruf nicht vergessen
  set a(value) {
    if (typeof value !== "number") {
      throw new TypeError("Must be number");
    }
    if (value !== this.#a) {
      this.#a = value;
      this.setAttribute("a", this.#a);
      this.#render();
    }
  }

  // Öffentliche Get-API für "b". Genau wie für "a", nur mit anderen Namen.
  get b() {
    return this.#b;
  }
  
  // Öffentliche Set-API für "b". Genau wie "a", nur mit anderen Namen, aber den
  // genau gleichen Typchecks, Attribut-Updates und Render-Aufrufen.
  set b(value) {
    if (typeof value !== "number") {
      throw new TypeError("Must be number");
    }
    if (value !== this.#b) {
      this.#b = value;
      this.setAttribute("b", this.#b);
      this.#render();
    }
  }
  
  // Verarbeitung von Attribut-Updates für "a" und "b"
  attributeChangedCallback(name, oldValue, newValue) {
    if (name === "a") {
      this.#a = Number(newValue);
    }
    if (name === "b") {
      this.#b = Number(newValue);
    }
  }
  
  // Nicht vergessen!
  get observedAttributes() {
    return ["a", "b"];
  }
  
  // Update, wenn das Element eingehängt wird
  connectedCallback() {
    this.#render();
  }
  
  // Anzeige des Ergebnisses, muss bei jeder Änderung von a oder b aufgerufen werden
  #render() {
    this.innerHTML = this.#a + this.#b;
  }
}

window.customElements.define("calculator-widget", CalculatorWidget);

Das ist deutlich zu viel Code für eine Komponente, die zwei Attribute als Zahlen interpretiert und addiert! Es fehlt an nichts Fundamentalen, denn die Plattform kann im Prinzip alles, was wir benötigen. Nur die Reibungsverluste der in den nativen APIs verwendeten Sprachmittel sind in einem Maße aus dem Ruder gelaufen, dass die Entwicklung von Vanilla Web Components nahezu unmöglich wird.

Ein jQuery für Web Components

Genau diesem Problem widmet sich Ornament, eine kleine Library für bessere Web-Component-APIs. Ornament ist kein Framework, sondern besteht nur aus ein paar Funktionen, die die genannten Problemzonen (und ausschließlich diese) von Web Components verbessern. Die 60 Zeilen Attribut-Albtraum von zuvor verwandeln sich mit Ornament in ca. 10 SLOC:

import { define, attr, number, reactive } from "@sirpepe/ornament";

// Regisiert die Klasse als Custom Element
@define("calculator-element")
class CalculatorElement extends HTMLElement {
  // Das komplette Attribut-Handling für "a" in einer Zeile
  @attr(number()) accessor a = 0;

  // Das komplette Attribut-Handling für "b" in einer Zeile
  @attr(number()) accessor b = 0;

  // Ruft die Methode "#render" auf, wenn sich "a" oder "b" ändern, plus einmal
  // bei der Initialisierung
  @reactive()
  #render() {
    this.innerHTML = this.a + this.b;
  }
}

Der Schlüssel zur Kürze ist die neue Decorators-Syntax (Dr. Axel erklärt), mit der sich Plugins ganz einfach an Klassenelemente herandeklarieren lassen. Decorators stehen kurz vor der Standardisierung und sind bereits heute mit Babel und TypeScript einsetzbar.

Unter der Haube verdrahtet Ornament lediglich, wie jQuery damals, ein paar APIs neu und existiert problemlos neben handgeschriebenem Vanilla-JavaScript. Wer mag, kann Ornament mit selbstgestrickten attributeChangedCallback() kombinieren! Mit einem treeshake-freundlichen zulässigen Höchstgewicht von unter 4k und allgemein minimalem Umfang sowie einfacher Migrations-Strategie ist die Library kein besonders großes Dependency-Risiko. Der z.Z.noch für den Decorators-Support nötige Build-Schritt wird überflüssig werden, sobald die Browser Decorators nativ unterstützen.

Kein weiteres Frontend-Framework!

Ornament ist kein Frontend-Framework und macht deswegen keinerlei Vorgaben in Hinblick auf App-Architektur, Template-Syntax, State-Management oder die vielen anderen Dinge, die von Frontend-Frameworks üblicherweise mitgeliefert werden. Das ist auch gar nicht das Ziel von Ornament. Wie jQuery soll Ornament ein zielgerichtetes Upgrade für die Developer Experience sein, das problemlos neben Vanilla-JavaScript und anderen Libraries existiert. Auf diese Weise können alle, die Bedarf an App-Architektur, Template-Syntax und State-Management haben, sich ihren eigenen Stack zusammenbasteln.

Wer kein Interesse an einem eigenen Stack hat, sondern einfach nur bunte Boxen in den Browser zu rendern gedenkt (und keine Probleme damit hat, sich an Dependencies zu ketten), ist bei einem der klassischen Frontend-Frameworks oder einem Web-Component-Framework wie Lit bestens aufgehoben. Wer aber auf Vanilla Web Components aufbauend etwas Eigenes, neues erschaffen möchte, kann sich mit Ornament das Leben erheblich vereinfachen … zumindest so lange, bis die Browser ihre diversen Probleme in den Griff bekommen und Ornament überflüssig machen. Denn anders als „richtige“ Frameworks wird Ornament, wenn sich die Webstandards rund um Web Components wie bisher weiterentwickeln, bald genauso überflüssig werden wie jQuery. Die Browser werden hoffentlich in Zukunft bessere APIs entwickeln und bessere Features für Attribut-Handling anbieten, als es im Moment der Fall ist.

Ich habe Ornament gebaut, weil mit den aktuellen APIs das Experimentieren an Web Components fast unmöglich ist. Jeder Versuchsaufbau besteht zu 90 % aus Boilerplate und Lifecycle-Callback-Verdrahtung, was so viel mentale Kapazität bindet, dass das eigentliche Experiment in den Hintergrund tritt. Wenn ich aber nun Attribute und Updates einfach per @-Deklaration an einzeilige Klasenkonstrukte herangebasteln kann, wird sehr viel Overhead eingespart und ich kann mich auf die eigentliche Aufgabe konzentrieren. Ein „richtiges“ Web-Component-Framework hilft hier auch nicht, denn Frameworks bringen immer ihre eigenen Abstraktionen und Konzepte mit, die nicht Gegenstand meiner Experimente sind ‐ ich möchte gern wissen, wie Web Components funktionieren, und nicht, was z.B. Lit macht. Deshalb macht Ornament nichts weiter, als ein wenig API-Streamlining zu betreiben, so dass ich entweder einfach meinen Versuch schreiben oder auf Ornament als Basis ein kleines Ad-hoc-Miniframework zusammenstricken kann. Es ist ganz wie früher mit jQuery: Write less, do more! Bis Ornament hoffentlich, früher als später, nicht mehr gebraucht wird.

Webtech-Erklärbär-Termine Q4 2023

Veröffentlicht am 5. September 2023

Die Pest hat mehr oder minder den Schwanz eingezogen und der Webtech-Erklärbär traut sich wieder aus seiner Höhle heraus. Ein ganz klein bisschen Angst macht mir dieser Terminplan, dem auch noch alle Inhouse-Termine fehlen, schon. Aber umso besser für euch: es gibt wieder diverse Möglichkeiten, sich von mir diversen Webtech-Kram auf Konferenzen überall im Land erklärbären zu lassen! Besonders empfehlen möchte ich die (finale Edition des Webworker-Stammtisch Ruhr, wo ich in Essen ab 19:00 Uhr ein bisschen HTML-Spezifikationsexegese betreiben werde. Aber der Rest kann sich auch sehen lassen:

Termine unpassend, Orte alle zu weit weg und Programme nicht genehm? Ich komme auch gerne mit einem maßgeschneiderten Workshop in eurer Firma vorbei – mich kann man ganz einfach mieten! Selbst bei einer Terminliste wie der für dieses Quartal mache ich dafür immer gerne Zeit.

Im Kaninchenbau der Array-Erkennung

Veröffentlicht am 1. August 2023

Auf Twitter (die Älteren unter uns erinnern sich sicher noch) fragte Nikolaus: „Woran, wenn nicht am Prototypen, erkennt Array.isArray() Arrays“ und bekam von mir eine viel zu kurze Antwort. Es folgt in diesem Artikel die Langfassung!

Heutzutage ist Array-Erkennung ganz einfach: die Spezifikationen legen direkt fest, dass Array.isArray() Arrays erkennt! Genau genommen verweist die Definition von Array.isArray() auf die spezifikationsinterne Funktion IsArray(), die mehr oder minder sagt: wenn ich auf ein Array angewendet werde, gebe ich true aus, ansonsten false. Arrays sind in ECMAScript sehr klar von normalen Objekten abgegrenzte Spezial-Objekte (sogenannte „Array Exotic Objects“) und daher eigentlich ohne großen Aufwand zu identifizieren. Das war allerdings nicht immer so einfach.

Was ist eigentlich ein Array?

Arrays sind Allerwelts-Bausteine von fast jedem JavaScript-Programm... was genau macht sie exotisch? Zunächst mal nicht viel: Arrays sind zu 99% von normalen JavaScript-Objekten nicht zu unterscheiden. Wir können beliebige Felder definieren, Funktionen wie Object.keys() mit ihnen verwenden und Operationen wie delete funktionieren auch:

let arr = [];
arr.someField = 23;
console.log(arr.someField); // > 23
arr[0] = 42; // Auch nur ein Objekt-Feld
console.log(Object.hasOwn(arr, "someField")); // > true
console.log(Object.hasOwn(arr, 0)); // > true
delete arr.someField;
console.log(Object.hasOwn(arr, "someField")); // > false

Das einzig offensichtlich Spezielle an Arrays sind eine eigene Literal-Syntax ([] statt {}) und ein Prototyp, der Methoden wie push() und splice() bereitstellt. Wir kommen dem Funktionsumfang von Arrays sehr nahe, wenn wir einfach ein neues Objekt mit Array.prototype als Prototyp anlegen:

let fakeArray = Object.create(Array.prototype);
fakeArray.push(23);
console.log(fakeArray[0]); // > 23
console.log(fakeArray.length); // > 1

Unser Fake-Array hat Inhalt (numerische Objekt-Keys) und Methoden wie push() definieren nicht nur neue Felder, sondern erhöhen auch die length um die Anzahl der neuen Elemente. Ein echtes Array macht aber noch mehr:

let fakeArray = Object.create(Array.prototype);
let realArray = [];

fakeArray.push(23);
realArray.push(23);

console.log(fakeArray[0], realArray[0]); // > 23, 23
console.log(fakeArray.length, realArray.length); // > 1, 1

fakeArray[1] = 42;
realArray[1] = 42;

console.log(fakeArray[1], realArray[1]); // > 42, 42
console.log(fakeArray.length, realArray.length); // > 1, 2

realArray.length = 0;
fakeArray.length = 0;

console.log(fakeArray.length, realArray.length); // > 0, 0
console.log(fakeArray[0], realArray[0]); // > 23, undefined

Beim Fake-Array ändert sich die length bei der Benutzung von Methoden, nicht aber bei direktem setzen von Indizes (z.B. fakeArray[1] = 42). Umgekehrt führt ein setzen der length auf 0 beim echten Array zum Löschen des Inhalts, beim Fake-Array hingegen ändert sich nichts am Inhalt. Wie kann das sein? Mit Sicherheit ist doch length ein Getter/Setter-Paar, das Array-Inhalt zählt oder verändert, richtig?

let realArray = [];

console.log(Object.getOwnPropertyDescriptor(realArray, "length"));
// > { value: 0, ... } - KEIN Getter/Setter-Paar

realArray[5] = true;

console.log(Object.getOwnPropertyDescriptor(realArray, "length"));
// > { value: 6, ... } - Magisches Update für "length"!

Oh. Anscheinend ist length doch eine ganz normale Daten-Property auf Arrays und kein Getter/Setter-Paar auf dem Prototypen. Aber wie funktioniert length denn dann?

Exotic Objects

In der ECMAScript-Spezifikation existiert das Konzept des „Exotic Object“, das bestimmte Sorten von Objekt (z.B. Arrays) von „Ordinary Objects“ abgrenzt. Exotic Objects können in einer beliebigen Reihe von Weisen vom Verhalten der normalen Ordinary Objects und auf diese Weise „magische“ Features wie length auf Arrays umsetzen. Als Ordinary gelten jene Objekte, die eine bestimmte Liste von Algorithmen implementieren (plus ein paar Extras für Funktionen) und „Exotic Objects“ weichen von diesen Standard-Bausteinen ab.

Im Falle von Arrays ist der Algorithmus für das Setzen von Properties abweichend definiert:

  1. Das Setzen der length verändert den Array-Inhalt
  2. Das Setzen von numerischen Feldern (d.h. von Array-Indizies) verändert die length
  3. Alles andere verhält sich wie bei normalen Objekten

Auf einem Array ein Feld wie z.B. arr.x = "Hello" zu definieren hat also den gleichen Effekt, als würden wir das auf einem normalen Objekt tun. Setzen wir jedoch arr[7] = 23, erhält length auf magische Weise ein automatisches Update und setzen wir die length auf einen neuen Wert, verändert sich der Array-Inhalte ebenso automagisch. Allein das Vorhandensein dieser einen Ausnahme für den Property-Set-Algorithmus erhebt Arrays in den exklusiven Club der Exotic Objects!

Die ECMAScript-Spezifikation untergliedert den Club der Exotic Objects anhand der diversen Non-Standard-Verhaltensweisen seiner Mitglieder noch weiter. Auf diese Weise kann die Spezifikation zwischen z.B. Arrays und Strings (die beide spezielle, aber unterschiedliche Operationen mit length und Indizes implementieren) auseinanderhalten. Allerdings passiert diese Unterscheidung allein auf der Ebene der Spezifikationen. Das Ziel der Specs ist, über präzise definierte Algorithmen ein bestimmtes beobachtbares Verhalten der Programmiersprache zu garantieren, doch die Algorithmen selbst sind nicht direkt aus JavaScript heraus beobachtbar. Wie mussten sehr genau hinsehen, um das besondere Verhalten von length überhaupt zu erkennen, und dieses Erkennen allein verrät uns nur, dass irgendwas besonderes los ist - was genau unter der Haube mit Array passiert, erklärt allein die Spezifikations-Lektüre.

Nun wissen wir, dass Arrays tatsächlich etwas besonderes sind: eine Subspezies einer besonderen Spezies von Objekt. Wie können wie diese Spezies jetzt in unserem normalen JS-Code von normalen Objekten unterscheiden?

Die Grenzen von Duck Typing und instanceof

Normalerweise ist Duck Typing das Mittel der Wahl, um in JavaScript den Typ von Objekten (näherungsweise) festzustellen, doch das funktioniert bei Arrays nicht besonders gut. Gerade aufgrund ihrer magischen length-Eigenschaft (anstelle eines Getter-Setter-Paars) sind Arrays von herkömmlichen Objekten kaum zu unterscheiden: ein überzeugendes Fake-Array mit numerischen Keys, Array.prototype und einer length-Eigenschaft ist, wie wir gesehen haben, schnell gebaut.

Eine denkbare Alternative zu Duck Typing ist instanceof, aber auch das hat seine Grenzen: wenn wir von Hacks, zu denen wir in Kürze kommen, erst mal absehen, liefert instanceof nur das richtige Ergebnis, wenn seine beiden Operanden aus dem gleichen Browsing Context (d.h. Fenster bzw. Frame) stammen:

// Array und Array-Constructor aus gleichem Frame
console.log([] instanceof Array); // > true
console.log(someFrame.contentWindow.arr instanceof someFrame.contentWindow.Array); // > true

  
// Array und Array-Constructor aus unterschiedlichen Frames
console.log([] instanceof someFrame.contentWindow.Array); // > false
console.log(someFrame.contentWindow.arr instanceof Array); // > false

Das ist ganz streng genommen nicht verwunderlich - Array und iframe.contentWindow.Array sind nun mal zwei unterschiedliche Objekte, und nur eins von beiden ist die Constructorfunktion von einem Array aus einem gegebenen Browsing Context. Hinzu kommt, dass wir mit @@hasInstance den instanceof-Operator ohnehin zu jedem beliebigen Ergebnis kommen lassen können:

class Yep {
  // Bestimmt das Ergebnis von "x instanceof Yep"
  static [Symbol.hasInstance]() {
    return true;
  }
}

console.log([] instanceof Yep); // > true
console.log({ foo: 42 } instanceof Yep); // > true

Und ja, streng genommen können wir den Array-Constructor so patchen, dass instanceof über Frame-Grenzen hinweg funktioniert:

// Normalerweise ist @@hasInstance auf Arrays nicht definiert...
Object.defineProperty(Array, Symbol.hasInstance, {
  value: (x) => Array.isArray(x)
});

Allerdings müssten wir innerhalb dieses Patches Array.isArray() benutzen, was uns auf der Suche nach einem Weg jenseits von Array.isArray() nicht wirklich weiterbringt. Für sich genommen ist und bleibt instanceof zur Array-Erkennung unbrauchbar und wir brauchen einen anderen, definitiven Weg, Arrays - die, egal aus welchem Frame stammend, nun mal Array Exotic Objects sind - zu identifizieren!

Der [[Class]]-Hack

Nachdem sich instanceof als nutzlos erwiesen hat, ist klar, wonach wir suchen: Wir brauchen einen Identifikationsmechanismus, der sich auf aus JS heraus zugängliche Aspekte stützt, die Array Exotic Objects eigen sind - unabhängig vom Browsing Context oder irgendwelchen Symbols auf irgendwelchen Klassen. Aus dieser Erkenntnis entstand in der grauen JavaScript-Vorzeit der folgende Hack:

let isArray = (x) => Object.prototype.toString.call(x) === "[object Array]"; // WTF?
console.log(isArray(42)); // false
console.log(isArray([])); // true

Wie funktioniert das? Im Prinzip per Informations-Leck! Normalerweise hat jede JavaScript-Objekt-Klasse seine eigene Implementierung von toString():

console.log({}.toString());
// > "[object Object]"
// Quelle: Object.prototype.toString()

console.log(function test(x) { return x * x; }.toString());
// > "function test(x) {return x * x;}"
// Quelle: Function.prototype.toString()

console.log([1, 2, 3].toString());
// > "1,2,3"
// Quelle: Array.prototype.toString()

Alle JavaScript-Objektklassen erben von Object.prototype und im Zuge dessen überschreiben sie die Basis-Implementierung von Object.prototype.toString() mit ihren eigenen Stringifizierungs-Algorithmen. Im Falle von Arrays stringifiziert dieser Algorithmus den Array-Inhalt und fügt ihn mit Kommata zusammen. Mittels Object.prototype.toString.call(someArray) umgehen wir aber diesen Array-eigenen Algorithmus und verwenden den Standard-Stringifizierungs-Algorithmus Object.prototype.toString() für unser Array. Und dieser Standard-Stringifizierungs-Algorithmus gibt nicht, wie viele glauben, einfach immer "[object Object]" aus!

Vor ECMAScript 2015 enthielten alle JavaScript-Objekte einen internen String-Wert namens [[Class]]. Die Doppeleckklammer-Notation ist die ECMAScript-Standard-Schreibweise für nicht-öffentliche Felder in Objekten. Ein so beschriebenes Feld ist ein reiner Spezifikationsmechanismus (vergleichbar mit den Non-Standard-Operationen von Exotic Objects) und sollte für Nutzer von JavaScript selbst nicht direkt beobachtbar sein. Soweit die Theorie.

In ES2015 und älter wurde [[Class]] allerdings in Object.prototype.toString zur Stringifizierung von Objekten verwendet! Einfach den Wert von [[Class]] in den String "[object XYZ]" an der Stelle von XYZ einsetzen und fertig! Bei ({}).toString() kam also nur deshalb"[object Object]" heraus, weil [[Class]] in Standard-Objekten eben "Object" war. Für Arrays, deren [[Class]] den Wert "Array" war, müsste also [object Array] herauskommen, doch da Arrays ihre eigene, [[Class]] ignorierende toString()-Implementierung mitbringen, passierte das im Normalfall nicht. Der einzige Weg, den [[Class]]-Wert eines Objekts mit eigener toString()-Implementierung sichtbar zu machen, besteht darin, das toString() von Object.prototype mittels call()-Methode auf die fraglichen Objekte anzuwenden.

Das Endergebnis war ein Hack, der eine löchrige Abstraktion in den ECMAScript-Spezifikationen ausnutzte. Die öffentliche Methode Object.prototype.toString erlaubte den Einblick in einen nichtöffentlichen Aspekt von der Spezifikationsmechaniken, womit wir Objekte genau wie die ES-Specs unterscheiden konnte. Das funktionierte mit Arrays und diversen anderen Standard-Objekt-Sorten recht zuverlässig, doch eine saubere Lösung zur Array-Erkennung sieht natürlich anders aus.

Der Weg zu Array.isArray()

Der Webentwickler-Community einen Mechanismus zur zweifelsfreien Identifikation von Arrays zu geben, war eine recht unkontroverse Idee. Anfangs (bis ES2015) stützte sich Array.isArray() noch auf [[Class]], doch später wurde das Regelwerk vereinfacht: true für Array Exotic Objects, andernfalls false. Das ändert im Endeffekt nicht viel, denn [[Class]] war genau so ein internes Spezifikationsdetail, wie es die Kategorie Array Exotic Object ist, doch am Ende ist es doch der etwas direktere Weg.

In heutigem ECMAScript existiert [[Class]] nicht mehr und Objekte (eingebaute wie auch in JS definierte) können ihre Stringifizierung per @@toStringTag selbst bestimmen. Alles, was von [[Class]] bleibt, sind ein paar zusätzliche Schritte in der heutigen Definition von Object.prototype.toString(), um Abwärtskompatibilität herzustellen. Array.isArray() erkennt seinesgleichen heutzutage ganz einfach per Definitionem und ist daher das am besten unhinterfragte Mittel der Wahl zur Array-Identifizierung. Klar, mit @@hasInstance aus dem Array-Constructor könnte JavaScript heutzutage Arrays auch über Frame-Grenzen per istanceof erkennbar machen, doch das lässt das Gebot der Abwärtskompatibilität natürlich nicht zu. Das wäre viel zu einfach.

TypeScript-Funktionen mit unknown-Parametern überladen - Wie, wann und warum

Veröffentlicht am 25. April 2023

Die Menge der Programmiersprachen-Features, die absolut radioaktiv sind und die niemand jemals benutzen sollte, ist meiner Überzeugung nach kleiner als viele glauben. Egal ob JavaScript-Features aus der Jungsteinzeit oder any in TypeScript, ich persönlich greife sehr gerne in die Mottenkiste, wenn es hilft, das aktuelle Problem zu lösen. Natürlich gehört zum Einsatz dieser... kontroversen Features immer auch der eine oder andere Safeguard, damit die immer auch vorhandenen negativen Aspekte der jeweiligen Features eingehegt werden. Aber selbst allgemein akzeptierte Sprachfeatures, selbst manche der komplett abgefeierten, haben Safeguard-Bedarf, denn auch sie können negative Auswirkungen haben. Das gilt unter anderem auch für unknown in TypeScript.

Selbstdisziplinierung mit unknown

Dem TypeScript-Typ unknown kann jeder andere Typ zugewiesen werden, aber er ist selbst nicht direkt benutzbar. Sinn und Zweck von unknown ist meist das Erzwingen eines Typechecks, wie z.B. im folgenden Beispiel:

function isString(input: unknown): boolean {
  return typeof input === "string";
}

In die Funktion isString() können wir jeden denkbaren Wert hineinstecken, denn einem Parameter von Typ unknown ist jeder andere Typ zuweisbar. Innerhalb der Funktion können wir aber mit input nichts anderes tun, als seinen eigentlichen Typ zu überprüfen (Type Narrowing, per Typcheck oder Vergleich) - andere Operationen sind mit unknown nicht zulässig. Im Prinzip würde als Parameter-Typ auch any funktionieren, denn input kann buchstäblich alles Mögliche sein. Allerdings passt in any nicht nur jeder Wert hinein, sondern mit any ist auch jede Operation möglich! Das bedeutet, dass wir versehentlich Fehler auslösen könnten:

function containsFooNumber(obj: any): boolean {
  return typeof obj.foo === "number"; // nachlässiger Typcheck nimmt an, dass obj nicht null/undefined ist
}

containsFooNumber({ foo: 42 }); // ok - true
containsFooNumber({ foo: "a" }); // ok - false
containsFooNumber({ bar: null }); // ok - false
containsFooNumber(undefined); // RUNTIME-FEHLER: cannot read "foo" of undefined

Tauschen wir any gegen unknown, kann die Funktion weiterhin mit allem möglichen Input gefüttert werden, doch wir sind gezwungen, die Funktion selbst umzuschreiben - der Zugriff auf obj.foo ist nur erlaubt, wenn wir sicherstellen, dass obj nicht null oder undefined ist:

// Nicht von TS akzeptiert
function containsFooNumber(obj: unknown): boolean {
  return typeof obj.foo === "number"; // TS: obj.foo geht nicht (obj ist unknown, d.h. ggf. null/undefined)
}

// Nur so funktioniert's
function containsFooNumber(obj: unknown): boolean {
  return (
    typeof obj === "object" &&
    obj !== null &&
    "foo" in obj &&
    typeof obj.foo === "number"
  );
}

Zusammengefasst können wir also sagen:

  • any kann alles zugewiesen werden und mit any kann alles gemacht werden
  • unknown kann alles zugewiesen werden und mit unknown kann nichts gemacht werden, außer es any/unknown zuzuweisen oder es in einen anderen Typ zu überführen

Als Selbstdisziplinierungsmaßnahme für die Autoren von Funktionen, die ihren Input einem Runtime-Typecheck unterziehen müssen/wollen, ist unknown also sehr nützlich. Aber welche Funktionen sind das eigentlich?

TypeScripts blinde Flecken

Wer TypeScript-Fans trollen möchte, kann ganz gut argumentieren, dass das gesamte Typsystem und all seine Features nur eine kollektive Illusion sind. Schließlich existieren TypeScript-Typen nur so lange, bis der Compiler aus den .ts-Dateien ganz normale .js-Dateien macht, in denen von TS nichts mehr zu sehen ist. Sobald es ernst wird und der Code tatsächlich laufen muss, sind sämtliche Typechecks nicht mehr vorhanden und theoretisch könnte es allerlei Fehler geben!

Darauf folgend müssen wir natürlich fragen, ob zu diesem Zeitpunkt noch Code existiert, der in Abwesenheit von Typechecks noch ein Problem auslösen kann. Ist das gesamte Projekt von vorn bis hinten in TypeScript geschrieben, entsteht schließlich nur Code, der alle Anforderungen des Typsystems erfüllt. Die Hürden des Typsystems werden zwar vom Compiler bei der Übersetzung von .ts nach .js abgebaut, aber es ist vorher nie Code entstanden, der in Abwesenheit dieser Hürden Fehler auslösen könnte. Die Typechecks sind zwar nicht mehr da, aber es ist kein Code entstanden, der diese Lücken ausnutzen könnte.

Das Problem hieran: kaum ein Projekt ist wirklich von vorn bis hinten in TypeScript geschrieben und selbst 100%-TS-Projekte müssen mit Systemen interagieren, die keine Typechecks haben. Dazu gehören:

  • Code ohne Typen, z.B. Dependencies mit schluderigem TS-Support oder eigener Code mit zu viel any
  • API-Endpunkte und Datenbanken, denn HTTP oder SQL werden nicht von TypeScript überprüft. Und selbst wenn die APIs oder Queries in TS geschrieben sind oder Typdefinitionen dafür gebaut/generiert wurden, entstehen die tatsächlichen Daten meist nicht unter den exakten Annahmen des angeflanschten Typsystems. Ein toll getypter HTTP-Endpunkt ist am Ende des Tages doch nur ein anderer Computer, auf dem alles mögliche los sein könnte.
  • JSON-Payloads z.B. aus LocalStorage könnten von alten Programmversionen erzeugt oder von Nutzern, Browser-Extensions oder sonstigen Dritten verändert worden sein.
  • Funktionsaufrufe von Dritten, die ggf. JavaScript statt TypeScript benutzen oder etwas zu freizügig any benutzen. Das ist besonders relevant, wenn das Projekt eine Library für den Einbau in anderen Projekten ist.

All diese blinden Flecken sind der Anlass, den Typ-Aluhut aufzusetzen und gründliche Runtime-Typchecks durchzuführen. Vertrauen ist gut, Kontrolle ist besser! Und zum Zweck der Selbstkontrolle verwenden wir unknown. Das könnte wie folgt aussehen:

type Options = {
  foo: number;
};

// Fehlschlagender Runtime-Typcheck wirft einen Fehler
function checkOptions(options: unknown): asserts options is Options {
  if (
    !options ||
    typeof options !== "object" ||
    !("foo" in options) ||
    typeof options.foo !== "number"
  ) {
    throw new TypeError("Runtime type check failed");
  }
}

// Öffentliche Funktion
export function publicFunction(options: unknown): void {
  // options hat hier den Typ "unknown"
  checkOptions(options);
  // options hat ab hier Typ "Options"
}

Unsere publicFunction() ist für den Einsatz durch Dritte gedacht und Dritten ist nicht zu trauen. Mit unknown zwingen wir uns innerhalb von publicFunction() zum Typecheck via checkOptions() und stellen damit zu 100% sicher, dass wir den options-Parameter erst anrühren, wenn wir sicher wissen, dass er exakt enthält, was wir erwarten.

Alles gut? Mitnichten! Denn falls Benutzer von publicFunction() TypeScript statt Vanilla JS verwenden, haben wir ihnen durch den Einsatz von unknown das Leben soeben schwerer statt leichter gemacht.

Die zwei Seiten von unknown

Einer der größten Vorteile von TypeScript ist die smarte Autovervollständigung, die uns z.B. bei einem Funktionsaufruf verrät (und überprüft), welche Parameter welchen Typ brauchen. Das Problem mit Funktionsparametern vom Typ unknown ist, dass dieses Feature uns dann auch tatsächlich unknown anzeigt:

Die Autovervollständigung zeigt an, dass ein Funktionsparameter vom Typ 'unknown' ist

Das ist zwar rein technisch korrekt, aber absolut nicht hilfreich. Die Funktion sollte definitiv mit Options gefüttert werden – unknown ist eine reine Vorsichtsmaßnahme! Die Vorsichtsmaßnahme verbirgt aber nun den eigentlichen Soll-Typ vor der Autovervollständigung. Und schlimmer noch: es gibt in der IDE nun auch keine keinerlei Typchecks mehr:

Eine TypeScript-Funktion akzeptiert einen offensichtlich zu einem Laufzeit-Fehler fürenden Wert, da er zu 'unknown' passt

Der Funktionsaufruf in Zeile 24 ist offensichtlich falsch und wird offensichtlich in einem Runtime-Fehler enden, doch der Editor sagt uns das nicht voraus – und das, obwohl der korrekte Typ nur wenige Zeilen vorher ordentlich definiert wurde.

Der Einsatz von unknown sorgt also im Endeffekt dafür, dass die Autoren von publicFunction() zwar vom Typsystem zur Durchführung eines Runtime-Typchecks angehalten werden, andererseits haben die Benutzer von publicFunction() keine sinnvolle Autovervollständigung mehr und auch keinerlei Typchecks (denn unknown kann jeden Wert zugewiesen bekommen). Anders gesagt: bei einer Funktion, die unknown als Parameter-Typ hat, profitieren die Autoren der Funktion von mehr Typsicherheit (sie können mit dem Parameter keinen Blödsinn anstellen), die Benutzer der Funktion haben praktisch gar keine Typsicherheit mehr. Das ist alles logisch und nachvollziehbar, aber alles andere als akzeptabel.

Die Lösung: unknown aus aufrufbaren Signaturen verbannen!

Meine Schlussfolgerung aus dem beschriebenen Problem mit unknown ist, dass Funktionen mit unknown als Parameter ein extrem heißer Kandidat für einen Overload sein sollten:

export function publicFunction(options: Options): void;
export function publicFunction(options: unknown): void {
}

Ein Overload einer Funktion ist in TypeScript eine alternative Funktionssignatur. Die eigentliche Funktionssignatur beschreibt die Implementierung, die Overloads beschreiben die (ggf. vielen verschiedenen) Aufruf-Signaturen, die durch die Implementierung umgesetzt werden. Hierbei können die Overloads durchaus restriktivere Signaturen bereitstellen, als die Implementierung eigentlich unterstützen würde:

function addOrConcat(a: string, b: string): string;
function addOrConcat(a: number, b: number): number;
function addOrConcat(a: bigint, b: bigint): bigint;
function addOrConcat(a: string | number | bigint, b: string | number | bigint): string | number | bigint {
  if (typeof a === "string" || typeof b === "string") {
    return String(a) + String(b);
  }
  if (typeof a === "bigint" || typeof b === "bigint") {
    return BigInt(a) + BigInt(b);
  }
  return a + b;
}

addOrConcat() hat dank seiner Overloads drei Signaturen, die aufgerufen werden können:

  1. (string, string) => string
  2. (number, number) => number
  3. (bigint, bigint) => bigint

Die Implementierung würde, reiner JavaScript-Logik folgend, auch andere Aufrufe wie etwa (string, bigint) => string unterstützen, aber die Overloads bieten nur die drei obigen Signaturen an; der theoretische Aufruf von (string, bigint) => string wird vom Compiler nicht akzeptiert. Dieser Aufruf würde zwar auf die Implementierungssignatur der Funktion passen, doch diese ist gewissermaßen privat und nur innerhalb der Funktion für die lokalen Typen von a und b relevant.

Das bedeutet für unsere publicFunction(), dass wir zeitgleich eine Implementierungssignatur mit unknown und eine Aufrufsignatur mit Options haben können! Innerhalb von publicFunction() zwingen wir uns zur Runtime-Überprüfung der Parameter, Aufrufende können diese Überprüfung zur Entwicklungs-Zeit vom Typsystem machen lassen:

export function publicFunction(options: Options): void;
export function publicFunction(options: unknown): void {
  // Hier ist options "unknown"
}

publicFunction(/* hier ist options "Options" */);

Wer TS nutzt, hat die erwartete Developer Experience, wer TS nicht nutzt (oder zu viel any verwendet), wird über Fehler erst (aber auch sicher) zur Laufzeit informiert. Alle Parteien haben die aus ihrer jeweiligen Perspektive korrekten Typchecks und die maximal mögliche Unterstützung ihrer IDE. Win-Win!

Bedingungen für unknown-Overloads

Es versteht sich von selbst, dass nicht jede Funktion mit unknwon einen Overload mit einem anderen Typ braucht. Zunächst mal braucht es für einen solchen Overload überhaupt einen passenderen Typen – und der ist nicht immer gegeben. Reine Typ-Überprüf-Funktionen haben prinzipbedingt unbekannte Typen als Parameter:

// der Parameter-Typ ist prinzipbedingt unbekannt
function isString(input: unknown): boolean {
  return typeof input === "string";
}

Zweitens ist es für einen Overload erforderlich, dass überhaupt jemand von dem Overload profitieren kann. Und das ist nicht der Fall, wenn die unbekannten Daten aus einem der erwähten blinden Flecken von TypeScript stammen:

function getSomeDataFromSomewhere(): unknown {
 // unwichtig
}

function checkAndProcessData(input: unknown): void {
  // unwichtig
}

function main(): void {
  checkAndProcessData(getSomeDataFromSomewhere());
}

Der unbekannte Input von checkAndProcessData() ist, ähnlich wie bei Typ-Überprüf-Funktionen, prinzipbedingt unbekannt, wenngleich er theoretisch einen bestimmten Typ haben sollte. Wir verwenden unknown allein, weil der Datenquelle getSomeDataFromSomewhere() nicht zu trauen ist, da diese ihre Daten aus einem von TypeScript blinden Flecken bezieht. Wir sparen uns an dieser Stelle den Overload, da es keinen menschlichen Nutzer gibt, der jemand davon profitieren könnte. Der Input für checkAndProcessData() kommt immer direkt aus getSomeDataFromSomewhere(), ist aus Vorsichtsgründen immer unknown, und wird niemals von Hand ausgeschrieben. Niemandes Autovervollständigung ist durch dieses unknown jemals beeinträchtigt.

Fazit

In der Hauptsache sind Overloads für Funktionen mit unknown-Parameter etwas für öffentliche Funktionen mit potenziellen menschlichen Nutzern. Das betrifft vor allem Libraries, aber auch API- und Service-Endpunkte aller Art; alle Funktionen, in denen wir uns als Autoren der Funktion zu gründlichen Runtime-Typchecks animieren möchten, ohne der TypeScript-Nutzerschaft die Developer Experience zu runinieren. unknown allein ist nützlich, hat aber notwendigerweise auch zur Folge, dass die Compile-Time-Typechecks für die Benutzer der betroffenen Funktionen kaputtgehen. Um das zu reparieren, brauchen meiner Meinung nach die entsprechenden Funktionen immer eine explizite Aufruf-Signatur ohne unknown und eine Implementierungssignatur mit unknown.