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.