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.

String.prototype.replace() ist eine Falle

Veröffentlicht am 11. Juli 2019

Einer meiner größten Kritikpunkte an JavaScript ist das Vorhandensein fieser Fallen. TypeScript ist schön und gut (und wird von mir fleißig verwendet), aber zur Not komme ich auch mit einem dynamischen Typsystem klar. Dass Dinge wie if und try keine Expressions sind, ist primitiv, aber die meisten Programmiersprachen haben auch ihre Rudimente aus der Steinzeit. Was mich hingegen wirklich auf die Palme bringt, ist alles, was wie eine bewusst gestellte Falle aussieht. Mein Lieblingsbeispiel hierfür ist das folgende Verhalten von parseInt():

// Euro
parseInt("0EUR"); // > 0

// Franc de la Coopération Financière en Afrique Centrale
parseInt("0XAF"); // > 175

Wird parseInt() kein zweites Argument für die Basis übergeben, wird mitnichten ein Standardwert verwendet, sondern es wird auf Basis des Input-Strings ein Wert erraten! Da der Währungscode des CFA-Franc nun mal XAF lautet und der dem Code vorangestellte Betrag 0 ist, denkt sich parseInt(), dass es einen Hexadezimalwert zu parsen hätte. Mit jedem anderen Betrag und Währungscode (oder einem explizit angegebenen zweiten Argument) taucht das Problem nicht auf:

parseInt("0EUR");  // > 0
parseInt("0GBP");  // > 0
parseInt("0USD");  // > 0
parseInt("0XAF");  // > 175
parseInt("10XAF"); // > 10
parseInt("7XAF");  // > 7

Das problematische Verhalten taucht unerwartet (denn ein Entwickler geht bei Standard-Library-Funktionen zurecht von sinnvollen Defaults aus) und nur für bestimmte Inputs auf. Es ist wirklich eine Falle, die überraschend zuschnappt und aus der man nur schwer wieder herauskommt. Mit Wachsamkeit und ESLint könnte man der Falle entgehen, aber dazu muss man die Falle grundsätzlich erst mal erwarten. Die Macken von parseInt() sind weithin bekannt, aber auch andere Standard-Funktionen in JavaScript haben ähnliches Zuschnapp-Potenzial.

Source Code als Modul-Import

Bei Warhol bin ich unter anderem für die Kern-Algorithmen verantwortlich, die Pattern Libraries erfassen und die erfassten Daten mit Production-Webseiten abgleichen. Besagte Algorithmen sind in gewöhnlichem Browser-JavaScript implementiert und werden durch automatische Browser-Fernsteuer-Prozesse (programmiert in Node.js) in Webseiten eingespeist, von wo aus die Algorithmen die ermittelten Daten nach Hause telefonieren. Das Einspeisen ist vergleichsweise knifflig, denn eine aus einem Algorithmus-Modul in ein Node.js-Script importierte JS-Funktion lässt sich nicht ohne weiteres in den Browser einspielen, der durch das Node.js-Script gesteuert wird – es handelt sich schließlich um komplett getrennte JavaScript-Runtimes!

Wie bekommt man also ein Bündel von Funktionen aus einer JS-Umgebung in eine andere JS-Umgebung? Eine denkbare Lösung wäre, das kontrollierende Node.js-Script die Algorithmus-Module nicht als Module importieren zu lassen, sondern stattdessen den Modul-Inhalt via fs.readFile() als String einzulesen. Dieser String ließe sich dann von Node.js aus bequem als Eval-Kommando in die Webseite einspeisen. Dazu müssten die Algorithmus-Module lediglich ein im Browser lauffähiges Bundle bereitstellen, was mit einem kleinen zusätzlichen Webpack-Kompilierschritt kein großes Problem darstellt. Diese Lösung ist aber nicht sehr robust, da hiermit die Büchse der Relative-Pfade-Pandora geöffnet wird. fs.readFile() benötigt den genauen Pfad der Datei, die eingelesen werden soll, doch bei einem per Package-Manager installierten Modul will man derlei ja eigentlich gar nicht wissen müssen! Kurzum: der pfadbasierte Ansatz sorgt für eine ziemlich suboptimale Developer Experience. Besser wäre doch, wenn so etwas funktionieren würde:

import { runtimeSource } from "@warhol/algorithm";
browserController.webpage.evalJSString(runtimeSource);

Der Source Code des Algorithmus als importierbarer String! Das würde die Benutzung des Algorithmus für die Browser-Fernsteuer-Scripts extrem bequem machen und reduziert das Problem auf einen zusätzlichen Schritt im Build-System des Algorithmus. Wie schwierig kann’s schon sein?

Ein besonders billiges Build-Script

Ziemlich schwierig, wie sich herausstellt! Es war auch nach mehrtägiger Recherche nicht möglich, die JS-API von Webpack dazu zu bringen, ein Bundle als JavaScript-String auszuspucken. Mein nächster Versuch führte mich zu babel-plugin-preval, einem Babel-Tool, das es ermöglicht, Code zur Compile-Zeit auszuführen. Vom Prinzip her wäre das genau die Lösung für mein Problem:

export const runtimeSource = preval`
  const fs = require('fs');
  module.exports = fs.readFileSync('/browserBundleSource.js', 'utf8');
`;

Doch als ich damals auf diesem Problem herumkaute, war Babel noch gar nicht Teil des Buildprozesses, da die Zielplattformen zu diesem Zeitpunkt nur modernste Browser und Node-Versionen waren. Extra für mein randständiges Build-Problem dieses eine Plugin (und dafür Babel) einzubauen erschien mir als Overkill.

Wenn etablierte Tools keine passende Lösung bieten, muss man sich selbst helfen. Und so kam das folgende Post-Build-Script in die Welt, um nach dem Übersetzen des Modul-Codes und dem Bauen des Webpack-Browser-Bundles beide Welten per Stringmanipulation zusammenzubringen:

const SOURCE = "./dist/browser/index.js";
const fs = require("fs");
const escape = require("js-string-escape");

const TARGETS = [
  "./dist/cjs/runtimeSource.js",
  "./dist/esm/runtimeSource.js",
];

const runtimeCode = escape(fs.readFileSync(SOURCE, { encoding: "utf-8" }));

for (const target of TARGETS) {
  const oldSource = fs.readFileSync(target, { encoding: "utf-8" });
  const newSource = oldSource.replace("__CODE_GOES_HERE__", runtimeCode);
  fs.writeFileSync(target, newSource);
  console.log("Added runtime code to", target);
}

Das Modul runtimeSource besteht nur aus export const runtimeSource = "__CODE_GOES_HERE__" und das Post-Build-Script ersetzt ganz einfach __CODE_GOES_HERE__ durch den Code, der im Webpack-Bundle steht. If it's stupid but it works, it isn't stupid! Und es hat lange Zeit ganz hervorragend funktioniert. Bis es irgendwann nicht mehr funktionierte, da ich unwissentlich in eine der Fallen der JavaScript-Standardbibliothek getappt war.

Heisenbug

Eines unschönen Abends vollführte ich ein Patch-Update der Projekt-Dependencies und der Code, der aus import { runtimeSource } from "@warhol/algorithm" kam, war plötzlich nicht mehr lauffähig. Interessant – debuggen wir das doch mal!

Es sei an dieser Stelle an den Matrjoschka-Charakter des Projekts erinnert:

  • Es gibt ein mit Webpack gebautes Browser-Bundle eines Moduls …
  • … das über das o.g. Build-Script als String in ein anderes Script eingefügt wird …
  • … damit dieser String in einen Node.js-Prozess importiert werden kann …
  • … um per Eval-Kommando in einem Browser-Kontext ausgeführt zu werden

Das Problem manifestierte sich bei Schritt 4, wo sinnvolles Debugging des in den ersten beiden Schritten erzeugten Codes naturgemäß nur noch bedingt möglich ist. Alles, was ich wusste, war, dass das Browser-Bundle (ein laaanger String aus durch mehrere Build-Schritte gejagtem JS-Code) nicht mehr funktionierte, nachdem es ein Update irgendwelcher Dependencies gegeben hatte. Nach einem Dependency-Rollback war wieder alles funktionsfähig, wurden die Patch-Releases wieder eingespielt, ging wieder alles kaputt.

Bei der Durchsicht des in Schritt 4 per evaluierten Browser-Bundles fiel auf, dass der dort enthaltene Code kein syntaktisch valides JavaScript war. Es machte den Anschein, als seien einzelne Code-Stücke (ohne Rücksicht auf Syntaxregeln) zufällig innerhalb des Bundles kopiert und eingefügt worden zu sein. Insbesondere tauchte verdächtig oft __CODE_GOES_HERE__ auf, was ja eigentlich hätte ersetzt werden sollen …

String.prototype.replace() ist eine Falle

Die replace(pattern, replacement)-Methode von Strings ist ausgesprochen vielseitig. Das pattern-Argument kann der zu ersetzende Substring oder ein regulärer Ausdruck sein, während für replacement entweder der neue String oder eine den neuen String generierende Funktion angegeben werden kann. Die Funktion bekommt Argumente übergeben, die für jeden pattern-Treffer z.B. einen Offset angeben, damit auch komplexere String-Manipulationen möglich sind. Allerdings sind besagte komplexere String-Manipulationen auch ohne Funktionen möglich, denn in replacement-Strings enthaltene besondere Patterns können ebenfalls komplexe String-Manipulationen beschreiben! Zu diesen besonderen Patterns gehört unter anderem $& – dieser Token soll vor dem Ersetzen durch den von pattern gematchten Substring ersetzt werden, etwa so:

"a b c".replace("b", "x$&");
// > "a xb c"

Anders gesagt: replace("b", "x$&") bedeutet nicht „ersetze b durch x$&“, sondern „ersetze b durch xb“. Das mag nützlich erscheinen, aber angenommen, der String für das zweite Argument würde auf die eine oder andere Art automatisch generiert und wäre nicht hardcoded oder anderweitig vorhersehbar …

oldSource.replace("__CODE_GOES_HERE__", autogeneratedTranspiledJsCode);
// Autsch :(

Nach dem Patch-Update der Dependencies enthielt der von Webpack erzeugte Code, der an die Stelle von __CODE_GOES_HERE__ gesetzt werden sollte, plötzlich diverse $&, die von String.prototype.replace() als magische Steuerzeichen interpretiert wurden. Dadurch wurde nicht einfach nur der JS-Code an die Stelle von, __CODE_GOES_HERE__ gesetzt, sondern vorher verändert und damit unbrauchbar gemacht.

Das Problem, wenn erst mal erkannt, ist natürlich relativ einfach zu reparieren:

// Automagische Pattern-Ersetzerei :(
oldSource.replace("__CODE_GOES_HERE__", autogeneratedTranspiledJsCode);

// Einfaches String-Ersetzen :)
oldSource.replace("__CODE_GOES_HERE__", () => autogeneratedTranspiledJsCode);

Wenn für replacement eine Funktion angegeben wird, dann kann diese die gleichen Pattern-Ersetz-Features wie ein String-replacement abbilden, kann es aber – anders als das String-replacement – auch unterlassen! Dadurch, dass autogeneratedTranspiledJsCode von einer Funktion zurückgegeben wird, werden Patterns wie $& nicht mehr als spezielle Steuerkommandos interpretiert, anders als wenn autogeneratedTranspiledJsCode selbst als zweites Argument übergeben wird.

Das Einfügen der Zeichenkette () => reparierte also meinen Heisenbug, wobei ich im Schnitt eine Stunde Arbeitszeit pro Zeichen aufgewendet habe (inkl. Schreiben dieses Artikels).

Falle oder Programmierfehler?

Es bleibt die Frage nach der Verantwortung: Sitzt der Auslöser für dieses Problem in JavaScript oder an der Tastatur, an der gerade diesen Artikel geschrieben wird? Ich bin, was meine Programmier-Fähigkeiten angeht, durchaus selbstkritisch. Meine Zimmerpflanzen müssen mich für den größten Stümper im gesamten Alpha-Quadranten halten, so oft wie ich laut über meine diversen Code-Unfälle vor mich hin schimpfe. Aber in diesem Fall bekommt JavaScript einen Gutteil meines Zorns ab.

Ich fühle mich von String.prototype.replace() in die Falle gelockt. Wenn man die Dokumentation nicht mit Argusaugen liest, könnte man sehr leicht auf den Gedanken kommen, das zweite Argument für replace(pattern, replacement) sei entweder ein einzusetzender String oder eine Factory-Function für den einzusetzenden String ist. Tatsächlich handelt es sich aber immer um eine Factory-Function für den einzusetzenden String, mit der besonderen Möglichkeit, diese Factory-Function auch als String mit magischen Steuerzeichen zu formulieren. Und was diese String-Factory-Function genau macht, hängt davon ab, was sie für magischen Steuerzeichen enthält.

RTFM halte ich an dieser Stelle für auch nicht besonders überzeugend. Natürlich könnte man von Nutzern einer Programmiersprache verlangen, alle Details der fraglichen Programmiersprache permanent im Kopf zu haben, aber das halte ich aus zweierlei Gründen für nicht besonders überzeugend. Zum einen könnte man damit jedwedes unerwünschte Verhalten in jeder Programmiersprache rechtfertigen; zum anderen ist das in der heutigen Welt mit absurd komplexen Programmiersprachen, Buildprozessen, Deploymentstrategien einfach von Normalsterblichen nicht mehr zu erwarten. Zugespitzt könnte man sagen, dass heutzutage fast jedes Computerproblem aus einem Homo Sapiens vor einem Bildschirm besteht und das, was auf dem Bildschirm stattfindet, trägt entweder zur Linderung oder zur Verschlimmerung des Problems bei. Es dürfte klar sein, welcher dieser zwei Kategorien String.prototype.replace() zuzuordnen ist.

Nun möchte ich nicht sagen, dass es diese Sting-Steuerzeichen-Option nicht geben sollte. Diese Funktionalität ist mindestens genauso sinnvoll, wie die Fähigkeit von parseInt(), hexadezimale Werte zu parsen. Was ich mir aber von einer Programmiersprache im Jahr 2019 wünschen würde, wäre, dass derlei Verhalten explizit angegeben wird und die Defaults nicht überraschend sind. Der zweite Parameter von parseInt() müsste als Standardwert einfach immer 10 sein (oder einfach nur immer gleich, von mir aus auch 16 oder 5), dann wäre an der Funktion nichts auszusetzen. Und bei String.prototype.replace() würde ich erwarten, dass es einen Steuerzeichen Opt-In gibt.

Bis auf Weiteres stellt someString.replace(a, b) mit einem String-Wert für b, der nicht hardcoded ist, sondern aus User-Input oder einer externen Datenquelle stammt, eine tickende Zeitbombe dar. Ich hoffe in eurer Codebase kommt so etwas nicht vor.

Finger weg vom function-Keyword!

Veröffentlicht am 14. August 2018

Das function-Keyword ist in modernem JavaScript ein Code Smell und man sollte es nicht mehr verwenden. Es einzusetzen ist nicht direkt ein schlimmer Fehler, aber seine Nutzung steht meines Erachtens unter dringendem Rechtfertigungsdruck. function-Functions haben fast keine wünschenswerten Fähigkeiten, dafür allerhand Legacy-Anhängsel, mit denen man sich eigentlich nicht mehr herumschlagen möchte. Stattdessen sollte man in so gut wie jedem Fall zu Arrow Functions oder zur Klassensyntax greifen.

Warum function-Functions mal nützlich waren

Dass JavaScript lange Jahre überhaupt zu gebrauchen war, lag unter anderem daran, dass function-Functions so unglaublich vielseitig sind. In Abwesenheit anderer Features (wie Klassen) übernahmen function-Functions in althergebrachtem JS gleich vier Rollen auf einmal:

  • Als normale Funktion foo() verwendet fungieren function-Functions als normale Funktionen bzw. Prozeduren
  • Mit new aufgerufene function-Functions funktionieren als Constructor-Funktionen
  • Mit new aufgerufene function-Functions dienen, wenn ihre prototype-Eigenschaft entsprechend bestückt sind, als eine Art Klassendeklaration (d.h. als der Ort, in dem Objekt-Methoden gesammelt werden)
  • Als Property eines Objekts aufgerufene function-Functions (z.B. obj.foo()) fungieren als Methode dieses Objekts.

Bemerkenswert, was ein einzelnes Sprachkonstrukt so alles leisten kann! Je nachdem wie ein Funktionsaufruf formuliert wird, kann die Funktion verschiedene Rollen einnehmen. Das Ganze funktioniert (unter anderem) indem jede Funktonsaufrufformulierung den Wert der in jeder Funktion verfügbaren magischen Variable this ändert. Aber genau damit fangen die Probleme von function-Functions an.

Die Nachteile von function-Functions

Der größte Haken an function-Functions ist, dass ihr Verhalten von der Formulierung des Funktionsaufrufs abhängt! Das gleiche Funktonsobjekt kann als foo(), obj.foo() und new foo() aufgerufen werden, obwohl es vermutlich für exakt einen dieser Einsatzzwecke ausgelegt wurde. Das Problem lässt sich einhegen, indem man den Strict Mode verwendet und in seine Funktionen Code einbaut, der die nicht eingeplante Aufrufvarianten entweder unterstützt oder mit Exceptions quittiert. Im besten Fall entsteht dabei unnötiger, fehleranfälliger Boilerplate-Code und im schlimmsten Fall macht sich niemand die Mühe.

// Diese Mühe machen sich die Wenigsten
function MyClass () {
  if (!(this instanceof MyClass)) {
    throw new Error("'new' fehlt");
  }
}

// Diese Mühe macht sich niemand
function myFunc () {
  if (this && this !== window) {
    throw new Error("Keine Klasse oder Methode");
  }
}

Ein weiterer Nachteil: selbst wenn man sich von OOP und Vererbung fernhält, muss man sich als Autor von function-Functions immer noch mit der Existenz von OOP-Features herumschlagen. Auch als normale Funktionen auslegte Funktionen haben, wenn sie function-Functions sind, noch immer die klassischen JS-OOP-Features this und prototype im Gepäck. Gleiches gilt für lästige Legacy-Anhängsel wie arguments, die man im Angesicht moderner Alternativen einfach nicht mehr braucht. In function-Functions sind sie aber stets verfügbar, nur in Arrow Functions nicht.

Zu guter Letzt kommen function-Functions in zwei Varianten daher: Funktionsdeklaration und Funktionsausdruck.

// Funktionsdeklaration
function foo () {}

// Funktionsausdruck
const foo = function () {}

Diese beiden Definitionen einer Funktion namens foo sind fast, aber nicht exakt gleich, da nur Funktionsdeklarationen gehoisted werden. Dadurch können sie aufgerufen werden, bevor sie im Code vorkommen. Ein wirklich notwendiges Feature ist das nicht, aber es ist eine weitere valide (überflüssige) Funktionsvariante mit subtilen Eigenheiten, deren Existenz wertvolle Gehirnkapazität belegt. Aber das muss alles nicht sein!

Alternativen zur function-Function

Statt sich mit den komplizierten function-Functions herumzuschlagen kann man sich mit gezielter Wahl alternativer Sprachmittel das Leben sehr viel leichter machen. So sind beispielsweise für „normale“ Funktionen Arrow Functions das eigentliche Mittel der Wahl. Sie haben kein eigenes this und können daher nicht als Objekt-Methode missbraucht werden. Die prototype-Eigenschaft fehlt und ein Aufruf-Versuch via new wird mit einer Exception quittiert.

const myFunc = () => {
  console.log("this", this);
  console.log("arguments", arguments);
};

myFunc.prototype;
// > undefined

myFunc();
// > "this" window {}
// > ReferenceError: arguments is not defined

new myFunc()
// > TypeError: myFunc is not a constructor

Eine Arrow Function ist eine wahre Funktion, nichts anderes – und die Befreiung von Legacy-Features wie arguments ist inklusive

Wer statt einfacher Funktionen eher Objekte und Methode braucht, ist mit einer Klasse am besten beraten. Nicht nur herrscht in Klassen standardmäßig Strict Mode, auch führt ein Aufruf ohne new zu einer Exception. In den Klassen notierte Methoden profitieren ebenfalls vom Strict Mode und können nur als Objekt-Methoden aufgerufen werden, ansonsten hat ihr this den Wert undefined:

class MyClass {
  foo () {
    console.log(this);
  }
}

const instance = new MyClass();
const foo = instance.foo;

instance.foo();
// > MyClass {}

foo();
// > undefined

MyClass()
// > TypeError: Class constructor MyClass cannot be invoked without 'new'

Klassen haben zwar wie function-Functions eine Deklarations- und eine Ausdruckssyntax, aber da erstere nicht gehoisted wird, ist das wirklich ein rein syntaktisches Detail:

// funktioniert nicht
new FooExpression();
const FooExpression = function () {};

// funktioniert!
new FooDeclaration();
function FooDeclaration () {}

// funktioniert nicht
new BarExpression();
const BarExpression = class {};

// funktioniert auch nicht
new BarDeclaration();
class BarDeclaration {}

Es zeigt sich: Klassen und Methoden sind präzise Werkzeuge um Objekte und ihre Methoden zu formulieren – und nichts anderes!

Verbleibende Use Cases für function-Function

Es gibt nach meinem Kenntnisstand zwei Fälle, in denen function-Functions das Mittel der Wahl sind. Der erste Fall betrifft TypeScript, wo die Syntax das Überladen der Typsignaturen von Funktionsdeklarationen (d.h. function-Functions), nicht aber von Arrow Functions zulässt:

// Überladen ist mit Arrow Functions nicht möglich
function foo <T> (input: T[], selector: (item: T) => 0 | 1): [ T[], T[] ];
function foo <T> (input: T[], selector: (item: T) => 0 | 1 | 2): [ T[], T[], T[] ];
function foo <T> (input: T[], selector: (item: T) => number): T[][] {
  // Implementierung
}

Fall zwei ist das Patchen von Prototypen. Hier braucht es eine Funktion, die mit this umgehen kann, aber außerhalb einer Klasse formuliert werden kann. Das kann nur eine function-Function sein:

SomeClass.prototype.newMethod = function () {
  // Implementierung
};

Letzteres ist schon ziemlich nah an der Grenze zum Hack angesiedelt. Unter Umständen nützlich bzw. nötig, aber ganz sicher kein Alltags-JavaScript.

Fazit

function-Functions sind an sich keine Katastrophe. Da sie aber viele verschiedene Use Cases auf einmal abdecken und diverse Legacy-Features mit sich herumschleppen, während es gleichzeitig pro Use Case eine einfachere, spezifischere Funktionssyntax ohne Legacy-Feature gibt, gibt es kaum noch einen Grund, function-Functions einzusetzen! In so gut wie jedem Fall sind Arrow Functions oder Klassen die bessere Wahl, da sie für ihre spezifischen Use Cases die spezifischeren Werkzeuge sind und sich in der Verwendung als weniger fehleranfällig erweisen. In heutigem JavaScipt steht jede function-Function unter Rechtfertigungsdruck.