Fragen zu HTML5 und Co beantwortet 26 - Globale IDs, Offline-Modus bei PWA, fortgeschrittenes TypeScript, Methoden-Syntax

Veröffentlicht am 3. Dezember 2019

Alle Jahre wieder sammeln sich in meiner Inbox genug Fragen zu Webtechnologien an, um einen Blogpost zu rechtfertigen. Dass es dieses Mal fast ausschließlich Fragen zu JavaScript und TypeScript sind, muss nicht sein – wenn ihr mir zu anderen Themen (oder eben diesen Themen) Fragen mailt oder mich auf Twitter anquatscht, werde ich sie beantworten und vielleicht eines Tages auch in einem Post wie diesem veröffentlichen!

Jens fragt: Bitte was? HTML-IDs legen globale JavaScript-Variablen an?!

Wenn ich einem HTML-Element eine ID gebe, kann ich das Element ohne document.getElementById() ansprechen? Einfach so, als globale Variable? Ist das Teil des Standards?

Ja, das ist Teil des HTML/DOM-Standards! Es handelt sich um den sogenannten named access on the Window object, der im Übrigen nicht nur mit IDs funktioniert. Dezent vereinfacht können als globale Variable verwendet werden:

  1. iframe-, embed-, form-, frameset-, img- und object-Elemente mit einem name-Attribut, dessen Wert als Variablenname fungiert
  2. Alle sonstigen Elemente mit einem id-Attribut, dessen Wert als Variablenname fungiert

Gibt es mehrere Kandidaten für einen Variablennamen (z.B. ein Iframe mit name="foo" und ein Div mit id="foo"), gilt folgende Rangfolge:

  1. Ist einer der Kandidaten ein Iframe mit passendem name, belegt es den Variablennamen; bei mehreren Iframe-Kandidaten belegt das erste Element im DOM den Namen
  2. Gibt es keinen passenden Iframe, kommen die Kandidaten aus Kategorie embed/form/frameset/img/object zum Zuge, wobei auch hier gilt, dass bei mehreren Kandidaten mit passendem name-Wert der Erste Kandidat im DOM verwendet wird
  3. Zuletzt kommen Kandidaten mit id-Attribut (bzw. der erste solche Kandidat im DOM) an Reihe.

Dass in einer Welt, die Funktionen wie document.querySelector() bietet, niemand dieses ausgesprochen fragwürdige „Feature“ verwenden sollte, versteht sich von selbst. Was macht es dann im HTML-Standard? Meine historischen Ausgrabungen legen nahe, dass es sich um ein altes Feature aus dem Internet Explorer handelt. Anderer Browser mussten, um alte IE-only-Seiten zu unterstützen, das Feature unter erheblichem Zähneknirschen auch implementieren und zack – fertig ist der Webstandard.

Es ist wichtig anzumerken, dass dieses „Feature“ keine Gefahr für andere globale Variablen darstellt. Ein window.foo = 42 wird nicht durch ein Iframe mit name="foo" überschrieben und dem eingebauten window.navigator droht keine Gefahr durch <div id="navigator">. Es handelt sich eher um eine Kuriosität als ein Problem.

Boris und Reinhard fragen: Wie einen Offline-Modus für eine PWA bauen?

Unsere Anwendung soll als Progressive Web App offline funktionieren. Das Laden der App funktioniert dank eines Service Worker auch schon problemlos. Aber wie können wir herausfinden ob der Nutzer on- oder offline ist, um zu entscheiden, ob Daten lokal oder auf dem Server gespeichert werden sollten?

Ob ein Nutzer online oder offline ist, kann man gar nicht herausfinden bzw. die Begriffe „online“ und „offline“ sind hier nicht besonders hilfreich. Landläufig versteht man unter „online“ eine Verbindung zum Internet, aber als Nutzer einer App interessiert mich das Internet™ an sich nicht; was ich eigentlich möchte, ist durch das Internet eine Verbindung zu einem bestimmten Endpunkt aufbauen und mit diesem Endpunkt einen spezifischen Satz Daten austauschen. Nicht nur braucht also die PWA eine Internetverbindung, sondern auch der Ziel-Server muss funktionsfähig und verbunden sein. Und zu allem Überfluss muss die Verbindung auch dergestalt sein, dass der Datenaustausch auch stattfinden kann, darf also z.B. nicht zu langsam sein.

Im Angesicht eines Web-Requests lautet die eigentliche Frage also nicht „bin ich online?“, sondern vielmehr „klappt dieser Request zu diesem Zeitpunkt?“. Und diese Frage lässt sich nur beantworten, indem man versucht, den fraglichen Request (und nicht etwa einen Ping-Request) sofort (und nicht etwa nach einem Ping) durchzuführen.

Ich würde für meine PWA ein Offline-First-Datenspeichern bauen. Wann immer der Nutzer in der App Daten abzuspeichern versucht, würde ich diese zuerst im lokalen Speicher ablegen und danach einfach versuchen, sie zum Server zu senden. Wenn das klappt, können die lokalen Daten bei Bedarf gelöscht werden. Und wenn nicht, dann probiert man es später einfach nochmal.

Mathias fragt: Wie kann man fortgeschrittenes TypeScript verständlich halten?

Kennst du Strategien, um TypeScript möglichst lesbar und verständlich zu halten, wenn man fortgeschrittene Features wie Mapped Types und Generics verwendet?

Es stimmt, dass fortgeschrittene TypeScript-Features für Uneingeweihte ziemlich kryptisch daherkommen können und dass unsachgemäßer Eingriff viel kaputt machen kann … aber das gilt eigentlich für alle möglichen fortgeschrittenen Features in allen möglichen Programmiersprachen. Und dass fortgeschrittene Features für nichtfortgeschrittene Nutzer ein Problem darstellen, ist etwas, um das wir grundsätzlich nicht herumkommen. Entsprechend denke ich, dass sich hier „nur“ die üblichen Software-Entwicklungs-Maßnahmen für den Umgang mit Komplexität ergreifen lassen:

  1. YAGNI: ich versenke gelegentlich Zeit in die ausgefeilt-generische Typ-Formulierung von Dingen, die am Ende in der Codebase in genau einer Form zum Einsatz kommen. Das ist dumm und ich sollte das nicht tun. Und auch sonst niemand. Fortgeschrittene Features sind weniger häufig wirklich nötig, als man glaubt.
  2. Automatisierung: die Typinferenz vom TypeScript hilft sehr beim Einsatz von Generics. Intelligent ausgelegte Funktionen machen nicht nur ihren Job, sondern sind auch benutzerfreundlich und bei TypeScript ist hierbei die Typinferenz von Generics als ein zusätzliches Kriterium neben Funktionalität und Lesbarkeit zu berücksichtigen. Wenn das mal nicht klappt, weil die Typinferenz eine bestimmte Konstruktion einfach nicht versteht, hilft vielleicht Abstraktion weiter: vor einen mit Generics gespickten Funktionsaufruf könnte man 2-3 nicht-generische Fassaden stellen, die für alle tatsächlichen Use Cases ausreichen (siehe YAGNI).
  3. Abstraktion: eine gute Typ-Transformation ist wie eine gute Funktion, also generisch, gut getestet und dem UNIX-Prinzip folgend. Und wie eine gute Funktion muss eine gute Typ-Transformation, wenn einmal geschrieben, auch von jenen verwendet werden, die nicht in der Lage wären, die Funktion/Transformation zu schreiben. Sie können als Black Boxes einfach benutzt werden und stören im Arbeitsalltag nicht, vor allem nicht, wenn sie gut dokumentiert in einem eigenen Modul wohnen. Stichwort Modul …
  4. Modularisierung: es lohnt sich, eine Library von TypeScript-Transformationen aufzubauen, die auch Nichtnerds einfach benutzen können – quasi ein Lo-Dash für Typen. Mit type-zoo und typelevel-ts gibt es solche Libraries von Dritten, aber es spricht nichts dagegen, auch einen eigenen Typ-Werkzeugkoffer zusammenzustellen und ihn versioniert und dokumentiert griffbereit, aber aus dem Weg zu halten.

Meine persönliche TypeScript-Philosophie ist, dass ich fortgeschrittene Features nur dann verwende, wenn sie mir wirklich viel Arbeit ersparen oder signifikante Sicherheitszuwächse bieten. Und das ist nur gegeben, wenn die entsprechenden Codeschnipsel nicht im Weg herumstehen und dabei die Typinferenz aufmotzen. Abgefahrenes TypeScript sollte man nur schreiben, wenn man als direkte Folge weniger abgefahrenes (oder mühsames) TypeScript schreiben kann.

Daniel fragt: ist die Methoden-Syntax für Objekt-Literale gut oder böse?

Ich habe deinen Artikel gelesen, in dem es darüber ging, dass man function-Funktionen nicht nutzen sollten und finde ihn super! Aber wie sieht es bei Funktionen in Objekt-Literalen aus? Arrow Functions, Methoden-Syntax oder doch function-Keyword?

Für Unterbringung von Funktionen im Objekt-Literal gibt es drei Möglichkeiten:

const obj = {
  foo () { return 42; },
  bar: () => { return 42; },
  baz: function () { return 42; },
}

Von diesen drei Kandidaten würde ich die Arrow-Function-Variante bar zuallererst verwerfen. Funktionen auf Objekten sollten im Normalfall schon als Methoden nutzbar sein und das klappt nicht mit dem lexikalischen this der Arrow Functions. Die verbleibenden Kandidaten foo () {} und baz: function () {} führen, wie der Babel-Output zeigt, zum gleichen Ergebnis, beide sind in letztendlich normale function-Funktionen (und müssen das auch sein).

Und was nimmt man nun? Ich persönlich greife zur Methoden-Syntax foo () {} weil sie einfach kürzer als das function-Keyword und in vielen Fällen weniger falsch als die Arrow-Function-Variante ist. Die Methoden-Syntax erlaubt außerdem die vollumfängliche Verbannung des function-Keyword aus meinem Code, was im Sinne der Code-Konsistenz vielleicht noch das stärkste Argument ist.

Weitere Fragen?

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

Hallo Welt, Warhol!

Veröffentlicht am 22. Oktober 2019

Seit ziemlich genau einem Jahr schrauben Hans und meine Wenigkeit an Warhol herum und so langsam ist es an der Zeit, auch an dieser Stelle einmal auf das Projekt hinzuweisen und ein paar Takte dazu zu erzählen. Falls Pattern Libraries und konsistentes Design am Herzen liegen, ist Warhol genau das Richtige für euch!

Was ist Warhol?

Warhol verwandelt Pattern Libraries in Tests. Eine Pattern Library (oft auch Styleguide genannt) in Warhols Sinn ist eine HTML/CSS-Seite, die Komponenten, Farbpaletten und Typografie vorgibt. Im Idealfall halten sich alle Entwickler bei der Umsetzung eines Web-Projekts an diese Vorgaben, tatsächlich passiert das nur selten. Das liegt zum einen daran, dass es schwer nachzuvollziehen ist, ob die Implementierung einer Komponente wirklich alle Vorgaben erfüllt, zum anderen sind die Vorgaben selbst nicht selten unvollständig oder widerspüchlich – z.B. verwenden Komponenten in einer Pattern Library oft Farben, die gar nicht in der Farbpalette vorgesehen sind. Warhols Ziel ist, Pattern Libraries aus der Rolle der groben Absichtserklärung zu befreien und sie in Unit Tests für Design zu verwandeln.

Mit nur wenig Konfigurationsaufwand extrahiert Warhol aus einer ganz normalen Pattern Library die Kernkonzepte (wie Farbpaletten und Komponenten) und stellt sicher, dass diese im echten Projekt genau so umgesetzt werden, wie es vorgesehen ist. Fehlerhaft implementierte Komponenten werden als Fehler gemeldet, ebenso nicht erlaubte Farben oder von der Pattern Library nicht abgedeckte Kombinationen von Schriftart, -farbe, und -schnitt. Wichtig dabei: anders als andere Tools arbeitet Warhol nicht mit Screenshots, sondern vergleicht HTML und CSS. Die mit Blindtext gefüllten Komponenten in der Pattern Library sind damit als Vorlage für die mit echtem Content gefüllten Production-Seiten problemlos zu gebrauchen.

Wie benutzt man Warhol?

Der erste Schritt ist das Anlegen einer Pattern Library. Wie genau diese umgesetzt ist, ist fast egal. Solange der Output eine HTML-Seite ist, kommt Warhol damit klar. Die Pattern Library muss dabei keinesfalls vollständig sein, auch wenn es nur eine Farbpalette gibt und nur eine einzige Komponente definiert wurde (oder gerne auch gar keine), funktioniert Warhol und testet immer nur das ab, was vorgegeben und konfiguriert wurde.

Als Zweites muss die Pattern Library für Warhol konfiguriert und zugänglich gemacht werden. Die Konfiguration erfolgt im Moment über eine simple JSON-Datei, die in etwa wie folgt aussieht:

{
  "patternLibUrl": "https://warhol.io/components",
  "breakpoints": [800, 1000],
  "theme": {
    "colors": {
      "sources": ".color-swatch"
    },
    "typography": {
      "sources": ".typography"
    }
  },
  "components": [
    { "name": "Navigation", "source": ".navigation" },
    { "name": "Button", "source": ".button" },
    { "name": "Button - Primary", "source": ".button.button--primary" },
    { "name": "Button - Secondary", "source": ".button.button--secondary" },
    { "name": "Teaser", "source": ".teaser" },
    { "name": "Teaser - Left", "source": ".teaser.teaser--align-left" },
    { "name": "Teaser - Post", "source": ".teaser.teaser--align-left.post" },
    { "name": "Author",  "source": ".author" }
  ]
}

Die Konfiguration besteht im Wesentlichen daraus, dass Warhol die öffentliche URL der Pattern Library, die Breakpoints und die CSS-Selektoren für verschiedene Konzepte der Pattern Library mitgeteilt werden. Ein Web-Interface zur Konfiguration von Pattern Libraries, Teams, API-Keys und allem was dazu gehört, gibt es selbstverständlich auch:

Als Nächstes kommt Warhols Crawler zu Besuch und verwandelt eure Pattern Library in Daten. In der ersten Version passiert das ausschließlich auf dem Rücken von Chrome, aber zumindest vom Prinzip her funktioniert auch der Firefox-Support schon. Der Browser schaut sich eure Pattern Library in den verschiedenen Breakpoint-Größen an, während Warhol das HTML und CSS eurer Komponenten und Farbpaletten mitschneidet. Die Daten landen in Warhols Backend und können dann verwendet werden, um Webseiten auf Konformität mit der Pattern Library zu testen.

In der ersten Version steht hierfür eine Browser-Erweiterung zur Verfügung. Ihr loggt euch einfach mit eurem Warhol-Account ein, ladet die Test-Daten für euer Projekt und schon seht ihr, wo ein Web-UI überall von der Pattern Library abweicht:

Wie der Screenshot zeigt, sind die Fehlermeldungen präzise und gnadenlos. Die Pattern Library im Beispiel enthält kein Weiß in der Farbpalette und erlaubt auch nirgends fetten Text, so dass die Browser-Extension sich über diese beiden Inkonsistenzen beklagt. Beides sind wohl eher Probleme in der Pattern Library als in der Webseite, aber auch solche Probleme müssen erst einmal gefunden werden. Mithilfe der Browser-Extension lassen sich Fehler in Production wie auch in der Pattern Library schnell diagnostizieren und reparieren.

Warhol kann Tests natürlich auch völlig autark ausführen und braucht keinen menschlichen Aufpasser oder Browser-Bediener. Ein Test-Crawler, der binnen weniger Minuten ganze Webauftritte oder Online-Shops nach Fehlern in allen Bildschirmgrößen in verschiedensten Browsern durchkämmt, ist in Arbeit.

Wann kann ich Warhol benutzen?

Bis zum öffentlichen Release wird noch etwas Zeit vergehen, aber die Alpha-Phase ist nah! Wir suchen deshalb mutige Tester, die sich in einem ersten Schritt Warhol demonstrieren lassen und bei dann weiter bestehendem Interesse Alpha-Tester werden wollen. Meldet euch hierzu einfach bei mir! Weitere Updates zu Warhol gibt es bei @wearewarhol und im Warhol-Newsletter, den ihr auf warhol.io abonnieren könnt.

Fragen zu HTML5 und Co beantwortet 25 - lange Klassen, Input-Labels, rekursive TypeScript-Typen, HTML-Attribute

Veröffentlicht am 22. August 2019

Im Laufe der Wochen hat sich mein Postfach wieder mit vielen Webtech-Fragen gefüllt, die per E-Mail schon beantwortet wurden und deren Veröffentlichung hiermit nachgeholt wird. Habt ihr auch Fragen zu HTML, CSS, JS, TypeScript oder anderen Frontend-Themen? Meldet euch bei mir per E-Mail oder auf Twitter!

Wie organisiere ich am besten exorbitant große TypeScript-Klassen?

In unserem Projekt haben wir eine Klasse namens „Document“, die von sich aus schon über 1000 TypeScript-Zeilen lang ist. Damit die Klassen nicht noch größer wird, versuchen wir Code in Mixins auszulagern, die wir mittels Object.assign() auf Document.prototype anwenden. Das funktioniert zur Laufzeit, aber im Editor gibts keine Hilfe, Autovervollständigung oder Typeahead für den via Object.assign() eingebundenen TS-Mixin-Code. Wie können wir den Code so aufbauen, dass er sowohl in JavaScript zur Laufzeit als auch in TypeScript zur Entwicklungs-Zeit vernünftig funktioniert? Brauchen wir eine riesige Vererbungs-Kette? Die würden wir eigentlich gerne vermeiden …

Mit einer gewaltigen Vererbungs-Kaskade würde Autovervollständigung wieder funktionieren, aber nur für Autovervollständigung lohnt sich der Umbau sicher nicht. Wir können auch nichts daran ändern, dass TypeScript die Object.assign()-Operation, die den Klassen-Prototypen mutiert, nicht so wirklich versteht. Prototype-Patchen ist etwas, das zur Entwicklungszeit (d.h. vor der Ausführung) für das Typsystem aufgrund der großen Dynamik des ganzen Vorgangs einfach nicht zu durchschauen ist.

Ein denkbarer Ausweg bestünde darin, dass wir das, was TypeScript von selbst nicht schafft, manuell herstellen. Parallel zum Patchen des Prototyps können wir einen Typ konstruieren, der den gepatchten Prototyp mit all seinen Mixins darstellt:

type PatchedClass <Base extends new (...args: any) => any, Mixin1, Mixin2> =
  Base extends new (...args: infer Args) => infer BaseInstance
    ? new (...args: Args) => BaseInstance & Mixin1 & Mixin2
    : never;

Dieser Utility-Typ konstruiert, vereinfacht gesagt, aus einer dem Typ einer Klasse und den Typen zweiter Mixins einen Typ, der aus der Constructor-Signatur des Typs der Input-Klasse plus den Feldern des Input-Klassen-Typs und der beiden Mixins besteht. Das Ganze ließe sich bei Bedarf auch für N statt 2 Mixin-Typen auslegen.

Am Ende behaupten wir dann einfach per Type Assertion, die gepatchte Klasse sei vom manuell konstruierten Typ. Dazu brauchen wir eine temporäre Klasse, die wir z.B. in einer IIFE verstecken können:

import FooMixin from "./moduleFoo";
import BarMixin from "./moduleBar";

class BaseClass { /* Klassen-Funktionalität */ }

const Document = ( () => {
  class __TempClass extends BaseClass { /* Klassen-Funktionalität */ }
  Object.assign(__TempClass.prototype, FooMixin, BarMixin);
  return __TempClass as PatchedClass<typeof __TempClass, typeof FooMixin, typeof BarMixin>;
})();

const myDocument = new Document();
// "myDocument" kennt foo() und bar() aus den Mixin-Modulen!

Funktioniert und ist dabei sogar gar nicht mal so schön!

Ich würde das als eine clevere Lösung bezeichnen, aber in Programmierfragen gilt: „clever“ ist das genaue Gegenteil von „intelligent“! Ich persönlich schreibe in solchen Fällen einfach immer eine exorbitant große Klasse mit zigtausend Zeilen, eine monströs große Funktion oder ein Modul mit laut „Best Practices“ viel zu vielen Zeilen. Die gezeigte TypeScript-Hexerei erlaubt es zwar, die Klasse in Einzelteile zu zerlegen, aber warum genau zerlegt man große Klassen oder lange Module? Das Ziel sollte immer sein, größere Programmteile konzeptuell aufzugliedern, um die einzelnen Basis-Bestandteile besser überschaubar, testbar und portierbar zu machen. Das geht aber nur dann, wenn der zu zerteilende Programmteil selbst kein Basis-Baustein ist, der ein fundamentales Konzept des Gesamtprogramms implementiert. Versucht man einen konzeptionell nicht weiter zu untergliedernden Basis-Baustein zu zerteilen, wird dieser nicht modularisiert, sondern lediglich auf viele verschiedene Module verteilt. Und davon sollte man dringend Abstand nehmen! Eine Klasse wird ja de facto nicht kürzer, indem man sie verteilt, nur noch schwerer zu überblicken. Der Name der Klasse in dieser Frage („Document“) und das Problem (viele Methoden) legen nahe, dass es sich hier nicht um etwas handelt, das sich sinnvoll aufgliedern lässt. Ein Dokument ist eben ein zentrales, wichtiges Konzept und es kann viel. Dann ist die Klasse eben lang und die Methoden zahlreich. Damit kann und sollte man sich arrangieren.

Programmierer haben heutzutage viel zu viel Angst vor langen Klassen und Funktionen. Wir sind drauf konditioniert, Code schön zu modularisieren und aufzuteilen, aber nicht jedes Real-World-Problem lässt sich schön modularisieren und aufteilen. Manches ist einfach inhärent komplex und lang und haarig. Das bedeutet nicht, dass man sich nicht an Modularisierung und knackig-kurzen Klassen und Funktionen versuchen sollte, aber wer versucht, unteilbares zu zerhacken, schafft es bestenfalls Komplexität zu verschleiern. Und das ist ebenso wenig wie eine bestimmte Maximallänge von Klassen und Modulen ein Ziel, das ein Entwickler haben sollte.

Wenn sich ein Programmierproblem nicht rein ästhetisch nicht zufriedenstellend lösen lässt, dann besteht die richtige Lösung darin, über die rein ästhetisch nicht zufriedenstellende Lösung einen erklärenden Kommentar zu schreiben und sie genau so zu lassen wie sie ist. Und dann hat die Klasse eben ein paar tausend Zeilen. Besser als das TypeScript-Gehacke oder eine konzeptionell keinen Sinn ergebende Vererbungskette ist das allemal.

Inputs und Labels verknüpfen: for-Attribut oder Verschachtelung?

Man kann ein Label einem Formularfeld per for-Attribut zuordnen oder indem man das Input-Element als Kind-Element in das Label steckt. Was ist besser?

Wenn es nach dem HTML-Standard geht, ist keins von beidem „besser“. Beide Verfahren haben den Effekt, das das Input-Element als labeled control mit dem Label-Element assoziiert wird und dann z.B. Klicks auf das Label ein Input-Element fokussieren. Das for-Attribut hat im Zweifelsfall Vorrang, aber wenn kein for-Attribut gesetzt ist, assoziiert sich das Label mit dem ersten Input-Element in seinen Kind- und sonstigen Nachfahren-Elementen.

Ich persönlich verwende, wann immer möglich, die Variante ohne for-Attribut. Dann müssen nämlich keine IDs jongliert und abgeglichen werden (IDs sind sowieso immer zu vermeiden) und ich habe weniger Arbeit und mache weniger Fehler.

Wie baue ich einen rekursiven TypeScript-Typ?

Ich möchte in TypeScript einen Array-Typ konstruieren, der entweder einen Wert oder sich selbst enthält, quasi Foo | Array<Foo> | Array<Array<Foo>> | ... bis zur Unendlichkeit. Wie geht das? Ich bin kurz davor ein @ts-ignore einzubauen!

Die Lösung besteht darin, einen Typ per type und einen per interface zu definieren:

interface RecursiveArray extends Array<Recursive> {}
type Recursive = number | RecursiveArray;

const a: Recursive = 23;
const b: Recursive = [ 23 ];
const c: Recursive = [ [ 23 ] ];
const d: Recursive = [ [ [ 23 ] ] ];
const e: Recursive = [ [ [ [ 23 ] ] ] ];

Grundsätzlich kann man in TypeScript in fast allen Fällen auf interface verzichten und stattdessen type verwenden, da type alles und mehr kann als interface … mit zwei Ausnahmen. Zum einen gibt es bei Interfaces (und nur bei Interfaces) Declaration Merging. Das bedeutet, dass die Deklarationen interface A { x: number } interface A { y: string } zum Typ A { x: number, y: string } zusammengefügt werden, was praktisch ist wenn globale Objekte wie z.B. Window gepatcht werden müssen. Zum anderen werden type-Deklarationen eager, interface-Deklarationen hingegen lazy ausgewertet. Um einen rekursiven Typ wie in der Frage zu beschreiben braucht man beides: das interface für die Selbstreferenz und den type für den mit | geschriebenen Union Type.

Darf ich mir einfach so HTML-Attribute ausdenken?

Ich verwende ein task-Attribut auf <button>-Elementen. Das funktioniert mit meinem JavaScript auch ganz gut, aber mein Kollege sagt, dass solche Attribute nicht unterstützt werden. Wer hat recht?

Die Antwort auf diese Frage hängt davon ab, was mit „Unterstützung“ des Attributs genau gemeint ist. Prinzipiell dürfen HTML-Elemente nur die Attribute haben, die sie laut Spezifikationen haben dürfen. „Unterstützt“ werden nicht-standardkonforme Attribute aber dennoch insofern, als der Browser sie parsen und über die meisten erwartenden APIs (z.B. in JS durch myButton.getAttribute("task") oder als CSS-Selektor myButton[task]) nutzbar macht. Ernsthaft problematisch sind bei frei erfundenen Attributen nur zwei Aspekte:

  • DOM-Getter und -Setter funktionieren nicht. Das bedeutet, dass z.B. Attribut-Updates nur per myButton.setAttribute("task", "42") funktionieren, über myButton.task = "42" hingegen nicht.
  • Sie sind nicht standardkonform und daher nicht zukunftssicher. Nichts hält die Browser der Zukunft davon ab, selbst eines Tages selbst ein task-Attribut einzuführen, dessen Funktionalität mit der hinzuerfundenen Funktionalität kollidiert.

Die beiden Aspekte sind in der Praxis nicht wirklich problematisch, aber es gibt auch einen Weg, selbsterfundene Attribute richtig zu machen: sie einfach durch ein vorangestelltes data- zu „namespacen“. Aus dem Attribut task wird data-task und schon gibt es keine Probleme mehr mit zukünftigen Browser-Updates. Durch die Dataset-API gibt es sogar bequeme DOM-Getter und -Setter!

Die Nachteile, die selbsterfundene Attribute ohne data-Prefix mit sich bringen, sind überschaubar. Aber da es nahezu null Aufwand bedeutet, dieses Prefix einzubauen, würde ich dazu raten. Es ist ein wenig richtiger und verursacht nur minimale Mehrarbeit. Das ist doch ein Deal!

Weitere Fragen?

Habt ihr auch dringende Fragen zu HTML, CSS, JavaScript oder TypeScript? Nur her damit! Alle Fragen in diesem Post wurden mir per E-Mail oder Twitter gestellt und ihr könnt das genau so machen! Einfach über einen der genannten Kanäle anschreiben oder gleich für einen Workshop das komplette Erklärbär-Paket bestellen.

TypeScript: Premature type annotations are the root of all evil!

Veröffentlicht am 31. Juli 2019

Typannotationen verwende ich in TypeScript wie Gewürze beim Kochen: gezielt und wohldosiert. Ich schreibe sie üblicherweise (nicht immer) in meine Funktionssignaturen und an Klassenfelder, meist aber nicht an Variablen (außer, wenn ich es eben doch mache). Das bedeutet, dass in meinem TypeScript-Code vergleichsweise wenig TypeScript vorkommt. Wenn ich Sie schreibe, dann stelle ich Typannotationen immer in den Dienst des durchgehenden, konsequenten Abbildens von Garantien. Insbesondere versuche ich bei Funktionen, innere und äußere Garantien konsistent zu halten. Was meine ich damit?

Garantien und Funktionssignaturen

Betrachten wir als Beispiel einmal die folgende Funktion:

import { SelectorMap } from "somewhere"; // Map<string, Element[]>

const getElements = (selectors: string[]): SelectorMap => { /* egal */ };

Die Funktion steht allein in einem Modul und fungiert als Abstraktion über document.querySelector(). Sie wählt Elemente anhand von CSS-Selektoren aus und ordnet Sie in einer Map ihrem jeweiligen Selektor zu:

const elements = getElements = (".foo", "#bar");
// elements === Map(2) { ".foo" => [ HTMLDivElement ], "#bar" => [ HTMLParagraphElement ] }

In ihrer TypeScript-Typsignatur beschreibt die Funktion eine Datentransformation. Ich nenne das die äußere Garantie; die Beschreibung dessen, was die Funktion für ihre Nutzer leistet. In diesem Fall besteht diese Leistung darin, dass sie aus einer Liste von Selektor-Strings … ja, was eigentlich genau macht?

Der Typ SelectorMap wird aus einem anderen Modul importiert und stellt offenbar gerade eine Map<string, Element[]> dar. Ich würde aber behaupten, dass SelectorMap und Map<string, Element[]> effektiv zwei unterschiedliche Typen sind, auch wenn sie kompatibel sind. Der Unterschied ist, dass SelectorMap ein von irgendwoher importierter Typ ist, der ggf. nicht unserer Kontrolle als Schreiber von getElements unterliegt. Das verändert die Garantie, welche die Funktionssignatur formuliert, auf ziemlich drastische Weise:

// ich liefere immer Map<string, Element[], SelectorMap ist mir egal
const getElements = (selectors: string[]): Map<string, Element[]> => { ... };

// ich verspreche, immer eine SelectorMap zu liefern, egal wie diese genau aussieht
const getElements = (selectors: string[]): SelectorMap => { ... };

Spürbar wird der Unterschied, wenn tatsächlich eines Tages durch ein Refactoring die Typen SelectorMap und Map<string, Element[]> inkompatibel werden:

  • Beim Rückgabetyp Map<string, Element[]> manifestiert sich das Problem an den Stellen, an denen getElements() aufgerufen wird und probiert wird, das Resultat als SelectorMap zu verwenden
  • Beim Rückgabetyp SelectorMap manifestiert sich das Problem an getElements() selbst, da es plötzlich seinem eigenen Anspruch nicht mehr gerecht wird.

Der Unterschied ist subtil, aber im Ernstfall durchaus spürbar. Auf diesen feinen Unterschied zu achten bringt weitreichende Konsequenzen mit sich, sobald es an die konkrete Implementierung von Funktionen und vor allem verschachtelte Funktionen geht.

Äußere vs. innere Garantien

Implementieren wir doch mal getElements() mithilfe von Array.prototype.map:

import { SelectorMap } from "somewhere"; // Map<string, Element[]>

const getElements = (selectors: string[]): SelectorMap => {
  const keyValuePairs = selectors.map( (selector) => {
    return [ selector, [ ...document.querySelectorAll(selector) ] ];
  });
  return new Map(keyValuePairs); // Nicht zulässig!
};

Dieser Code funktioniert so nicht, denn TypeScript lässt uns nicht keyValuePairs in new Map() stecken. Die Typinferenz ermittelt für keyValuePairs den Typ Array<Array<string | Element[]>> statt des von uns beabsichtigten (und vom Map-Constructor benötigten) Array<[string, Element[] ]>. Für dieses Problem gibt es (jenseits von any) zwei Lösungen. Zum einen könnten wir TypeScript mit as const beibringen, dass der Rückgabetyp von (x: A) => [ x, x ] als Tuple [ A, A ] statt als Array A[] zu verstehen ist. Leider macht das auch den Tuple-Inhalt readonly, was bei es für unser Beispiel unbrauchbar macht – denn in der SelectorMap sollen schließlich Arrays und keine ReadonlyArrays landen.

Eine Rückgabe-Typannotation [ string, Element[] ] für den Map-Callback würde da schon besser funktionieren, ebenso wie die Typannotation Array<[ string, Element[] ]> an der Variable keyValuePairs:

import { SelectorMap } from "somewhere"; // Map<string, Element[]>

const getElements = (selectors: string[]): SelectorMap => {
  const keyValuePairs = selectors.map( (selector): [ string, Element[] ] => {
    return [ selector, [ ...document.querySelectorAll(selector) ] ];
  });
  return new Map(keyValuePairs); // klappt
};

const getElements = (selectors: string[]): SelectorMap => {
  const keyValuePairs: Array<[ string, Element[] ]> = selectors.map( (selector) => {
    return [ selector, [ ...document.querySelectorAll(selector) ] ];
  });
  return new Map(keyValuePairs); // klappt
};

Das Problem an dieser Stelle ist, welche Garantien diese Typannotationen an der inneren Funktion genau geben. Die äußere Funktion getElements() verspricht, aus einem Array von Selektor-Strings eine SelectorMap zu machen (was immer das auch im einzelnen genau sein mag), der innere Map-Callback hingegen verspricht, aus einem String ein Tuple [ string, Element[] ] zu machen. Was haben SelectorMap und [ string, Element[] ] gemeinsam? Direkt eigentlich gar nichts! Wir können davon ausgehen, dass die Keys und Values von SelectorMap mal kompatibel zu den Typen string und Element[] waren (es vielleicht sogar auch immer noch sind), aber ist das wirklich, was wir ausdrücken wollen?

Was der Map-Callback eigentlichen garantieren sollte, ist, dass er aus einem Teil-Input (einem string von vielen) einen Teil-Output (ein Name-Wert-Paar für SelectorMap) macht. Stattdessen garantiert er, aus einem string ein Name-Wert-Paar [ string, Element[] ] zu machen. Die jeweiligen Outputs mögen kompatibel sein, sind es aber definitionsgemäß nur so lange, bis sich die Typen ändern. Die äußere Garantie der Haupt-Funktion und die innere Garantie des Map-Callbacks sind zeitweise kompatibel, aber sie bauen nicht aufeinander auf.

Konsistente Garantien durch Hilfs-Typen

Was wir an dieser Stelle eigentlich ausdrücken möchten, ist durch einen Hilfs-Typ wie Entry<Map> zu bewerkstelligen. Dieses schöne Stück schwarze TypeScript-Magie …

type Entry <M extends Map <any, any>> =
  M extends Map <infer Key, infer Value>
    ? [ Key, Value ]
    : [ any, any ];

… extrahiert aus einem Map-Typ einen Tuple-Typ (den Entry) bestehend aus dem Typ des Keys und dem Typ des Values. Der Typ Entry<M> erwartet als Eingabe-Typargument eine Map (Bedingung M extends Map<any, any>) und versucht mittels Typinferenz (infer-Keyword) die Typen der Keys und Values der Map zu ermitteln, jeweils mit any als Fallback. Mit diesem Helferlein ist es uns jetzt möglich, die in der Typsignatur von getElements() gegebene äußeren Garantien in die Implementierung hereinzutragen:

const getElements = (selectors: string[]): SelectorMap => {
  const keyValuePairs = selectors.map( (selector): Entry<SelectorMap> => {
    return [ selector, [ ...document.querySelectorAll(selector) ] ];
  });
  return new Map(keyValuePairs);
};

Das Garantiegeflecht ist jetzt frei von potenziellen Widersprüchen: getElements() macht aus dem Gesamt-Input den Gesamt-Output SelectorMap und die Teiloperationen machen aus Teilen des Gesamt-Inputs Teile des Gesamt-Outputs (Key-Value-Tuples für SelectorMap, wie immer die auch aussehen mögen).

Der Härtest im Refactoring

Bei diesen Überlegungen handelt es sich mitnichten um zweckfreie Abstraktions-Astronautik. Je nachdem wie wir das Garantiegeflecht ausdrücken, manifestieren sich Fehler an sehr unterschiedlichen Stellen. Simulieren wir doch mal ein Refactoring und machen aus der SelectorMap, ehemals eine Map<string, Element[]>, eine Map<string, HTMLElement[]>. Wichtig ist: HTMLElement ist ein Subtyp von Element, d.h. in der neuen Map sind z.B. SVG-Elemente nicht mehr willkommen. Was macht diese Änderung von SelectorMap mit unseren verschieden annotierten Implementierungen?

import { SelectorMap } from "somewhere"; // neuerdings Map<string, HTMLElement[]>

// mit [ string, Element[] ] für den Callback
const getElements = (selectors: string[]): SelectorMap => {
  const keyValuePairs = selectors.map( (selector): [ string, Element[] ] => {
    return [ selector, [ ...document.querySelectorAll(selector) ] ];
  });
  return new Map(keyValuePairs); /* Fehler hier */
};

// mit Array<[ string, Element[] ]> für die Variable
const getElements = (selectors: string[]): SelectorMap => {
  const keyValuePairs: Array<[ string, Element[] ]> = selectors.map( (selector) => {
    return [ selector, [ ...document.querySelectorAll(selector) ] ];
  });
  return new Map(keyValuePairs); /* Fehler hier */
};

// mit Entry<SelectorMap>
const getElements = (selectors: string[]): SelectorMap => {
  const keyValuePairs = selectors.map( (selector): Entry<SelectorMap> => {
    return [ selector, [ ...document.querySelectorAll(selector) ] /* Fehler hier */ ];
  });
  return new Map(keyValuePairs);
};

In allen drei Fällen warnt uns TypeScript vor Fehlern, was schon mal sehr positiv ist. Allerdings handelt es sich um zwei sehr unterschiedliche Fehler! In den ersten beiden Fällen läuft das Problem darauf hinaus, dass unser Garantiegeflecht plötzlich Widersprüche enthält und der Fehler tritt an der Stelle zutage, wo die unterschiedlichen Garantien von äußerer Funktion uns innerem Callback nicht mehr zusammenpassen. Beim letzten Beispiel verweist die Fehlermeldung hingegen auf die Stelle, wo unser in sich konsistentes Garantiegeflecht mit inkompatiblen anderen Programmteilen kollidiert bzw. wo unsere TypeScript-Garantien (Values sind HTMLElement) vom Runtime-JavaScript (document.querySelectorAll() liefert Element) nicht mehr eingehalten werden. Und eigentlich ist letzteres ja das, was wir von TypeScript wollen! Es soll uns vor unerwartetem Runtime-JavaScript warnen und uns nicht dafür tadeln, dass wir uns in unseren eigenen Typannotationen verlaufen haben!

Es handelt sich bei den Fehlern in den ersten beiden Beispielen nicht um andere Manifestationen des gleichen Problems, sondern um hausgemachte Extra-Fehler, die aus den Widersprüchen in den Typsignaturen erwachsen. Sobald wir anfangen, die Fehler zu reparieren, d.h. Element durch HTMLElement zu ersetzen, landen wir als Folgefehler bei genau dem gleichen Problem, bei dem wir mit dem dritten Beispiel schon von Anfang an waren:

import { SelectorMap } from "somewhere"; // neuerdings Map<string, HTMLElement[]>

// mit [ string, HTMLElement[] ] für den Callback
const getElements = (selectors: string[]): SelectorMap => {
  const keyValuePairs = selectors.map( (selector): [ string, HTMLElement[] ] => {
    return [ selector, [ ...document.querySelectorAll(selector) ] /* Fehler jetzt hier */ ];
  });
  return new Map(keyValuePairs);
};

// mit Array<[ string, HTMLElement[] ]> für die Variable
const getElements = (selectors: string[]): SelectorMap => {
  const keyValuePairs: Array<[ string, HTMLElement[] ]> = selectors.map( (selector) => {
    return [ selector, [ ...document.querySelectorAll(selector) ] /* Fehler jetzt hier */ ];
  });
  return new Map(keyValuePairs);
};

Nachdem wir unsere eigenen Widersprüche aufgelöst haben, können wir uns endlich an die Beseitigung des eigentlichen Problems kümmern, das nun mal an document.querySelectorAll() hängt. Aber besser wäre es natürlich, wenn wir gar keine eigenen Widersprüche in unseren Code einbauen würden und immer direkt zum Kern des Problems kommen würden. Dafür müssten wir als TypeScript-Nutzer drei Dinge tun:

  • Funktionen und Klassen konsequent mit Typsignaturen ausstatten, damit diese als Eckpfeiler unseres Garantiegeflechts fungieren können
  • Transparente Typannotationen ohne Widersprüche schreiben! Das bedeutet vor allem, dass wir bei verschachtelten Funktionen o.Ä. die Typen für die Signaturen innerer Funktionen mit Utilities wie Entry<T> aus den Typen für die Signaturen der äußeren Funktionen konstruieren
  • Weniger Typannotationen schreiben, vor allem an Variablen nur sehr dosiert

Oder anders formuliert: wir müssen über Typannotationen nachdenken und sie nicht einfach überall hinkleistern, nur weil wir können.

Weniger Typannotationen schreiben!

Nichts baut schneller Widersprüche in TypeScript ein, als das Folgende:

let x: Thing = createStuff();

Solange die Typsignatur createStuff() eine ordentliche Typsignatur hat, ist die Annotation Thing an x überflüssig – das findet die Typinferenz von TypeScript auch alleine raus. Schlimmer noch: wenn createStuff() umgebaut wird, und plötzlich kein Thing mehr, sondern ein PartialThing (etwas, das prinzipiell im Programm Sinn ergibt, aber technisch gesehen kein Subtyp von Thing ist) produziert, gibt es hier einen Fehler. Wieso eigentlich? Es ist ja nicht so, dass die JavaScript-Variable x kein PartialThing aufnehmen könnte. Sollte x später von etwas verwendet werden, dass ein Thing erwartet, wird dann eben an dieser Stelle scheppern:

let x = createStuff(); // liefert PartialThing
consumeThing(x);
// Fehler!
// An der Stelle wo es ein Problem wird!
// WENN es kein PartialThing akzeptiert!

Der Schlüssel zu widerspruchsfreien Typannotationen sind zuallererst weniger Typannotationen – nicht, weil Typannotationen schlecht wären, sondern weil sie zum Formulieren von Widersprüchen einladen. Zum Glück sind sie aber an vielen Stellen dank Typinferenz nicht nötig und eine gute IDE zeigt auch an, welchen Typ die Typinferenz für eine Variable ermittelt hat. Typannotationen gehören also in der Regel nur an Funktionen und Klassen und nur ausnahmsweise (da wo es sinnvoll und/oder nötig ist) an frei laufende Variablen.