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

Veröffentlicht am 25. April 2023

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

Selbstdisziplinierung mit unknown

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

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

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

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

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

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

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

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

Zusammengefasst können wir also sagen:

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

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

TypeScripts blinde Flecken

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

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

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

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

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

type Options = {
  foo: number;
};

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

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

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

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

Die zwei Seiten von unknown

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

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

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

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

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

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

Die Lösung: unknown aus aufrufbaren Signaturen verbannen!

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

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

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

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

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

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

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

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

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

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

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

Bedingungen für unknown-Overloads

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

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

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

function getSomeDataFromSomewhere(): unknown {
 // unwichtig
}

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

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

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

Fazit

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

Canvas-Hardwarebeschleunigung per JavaScript selektiv abschalten

Veröffentlicht am 31. Januar 2023

Der Edge rendert auf manchen Laptops PDFs komisch, wenn Hardwarebeschleunigung an ist, sprach der Kunde zu mir und blickte hoffnungsvoll. Und es stimmte – oft (nicht immer), wenn Edge auf den gerade frisch in seiner Firma ausgerollten Laptops (nicht den alten Laptops) ein PDF in der Inhouse-Webapp zu rendern versuchte, waren einzelne Buchstaben entweder ganz unsichtbar oder es fehlte die Hälfte der relevanten Pixel. Sobald die Hardwarebeschleunigung in den Browser-Optionen abgeschaltet war, sah alles wunderbar aus. Das Problem trat ausschließlich auf den neuen Laptops auf, ausschließlich bei aktivierter Hardwarebeschleunigung und ausschließlich im Edge, bei nicht allen, aber vielen PDFs.

Anders als die initiale Beschreibung des Bugs vermuten lässt, war es gar nicht so schwer, für das Problem einen passenden Workaround zu bauen: einfach die modernsten DOM-APIs mit den übelsten JavaScript-Worst-Practices der Jungsteinzeit kombinieren und schon ist perfektes PDF-Rendering gesichert!

Ursachensuche

Etwas wie der beschriebene Rendering-Bug konnte eigentlich nicht an irgendwelchem JavaScript aus der Feder meines Kunden liegen, da das überwiegend aus pragmatischem Glue Code für diverse Libraries besteht. Also war an der Zeit, die Dependencies zu durchkämmen, was die eigentliche Ursache des Problems schnell zutage förderte. Das PDF-Rendering in der Webapp übernimmt PDF.js (als eine Dependency einer Dependency einer anderen Dependency) und der Bugtracker von PDF.js ist nicht arm an Meldungen des genau gleichen Problems (Beispiel 1, Beispiel 2). Die eigentliche Ursache für den Bug ist aber nicht PDF.js selbst, sondern ein Glitch in der Rendering-Engine des Browsers.

Das Ergebnis der Ursachenforschung ist also: um das Problem wirklich zu reparieren, müssten die C++-Ninjas im Chrome/Blink-Team auf den Plan gerufen werden. Da das, selbst wenn es zeitnah in einem Bugfix münden sollte, vermutlich eine lange Rollout-Phase nach sich ziehen würde, brauchten wir zur Behandlung des akuten Problems einen JavaScript-basierten Workaround. Am einfachsten wäre es, per JS die Hardwarebeschleunigung zu deaktivieren, sollte sich das als machbar herausstellen. Und siehe da: das geht tatsächlich!

Pro und Contra von Canvas-Hardwarebeschleunigung

Im Kontext von Webbrowsern bedeutet Hardwarebeschleunigung, den Hauptprozessor zu entlasten, indem Rendering-Aufgaben an den dezidierten Grafik-Chip (sofern vorhanden) des Rechners übergeben werden. Der Grafik-Chip ist auf Renderei spezialisiert und deshalb darin viel flotter als die generalistische CPU, wovon sowohl CSS als auch Canvas-Elemente profitieren können. Anders als bei CSS ist allerdings Rendering nicht die einzige Aufgabe, die ein Canvas-Element wahrnehmen kann.

Das Canvas-Element unterstützt diverse APIs für Operationen rund um Grafik. Von diesen APIs haben allerdings längst nicht alle etwas mit dem Anzeigen irgendwelcher Pixel zu tun! Wenn wir beispielsweise in JavaScript-Zugriff auf die rohen Pixeldaten von Bilden brauchen, ist das Canvas-Element das Mittel der Wahl:

function loadImage(src) {
  return new Promise((resolve) => {
    const img = new Image();
    img.src = src;
    img.onload = () => resolve(img);
  });
}

// JPEG laden
const img = await loadImage("image.jpg");

// Nicht in DOM eingehängtes Canvas-Element erzeugen
const canvas = document.createElement("canvas");
canvas.width = 300;
canvas.height = 300;

// 2D-API
const ctx = canvas.getContext("2d");

// Bild auf (unsichtbare) Canvas zeichnen
ctx.drawImage(img, 0, 0);

// Pixeldaten abrufen
const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);

// RGB-Werte invertieren
let i = 0;
while (i < imgData.data.length) {
  imgData.data[i] = 255 - imgData.data[i++]; // r
  imgData.data[i] = 255 - imgData.data[i++]; // g
  imgData.data[i] = 255 - imgData.data[i++]; // b
  i++; // a
}

// Invertierte Pixeldaten auf Canvas zeichnen
ctx.putImageData(imgData, 0, 0);

// Export als Data-URL, Anzeige als Image-Element
canvas.toBlob(async (blob) => {
  document.body.append(await loadImage(URL.createObjectURL(blob)));
});

Wichtig hier: das Canvas-Element ist zu keinem Zeitpunkt sichtbar, da es auch nicht angezeigt werden muss! Wir nutzen seine 2D-APIs (v.a. getImageData()), um die Pixeldaten des geladenen JPEG-Bildes in die Finger zu bekommen, die wir daraufhin invertieren und exportieren. Das Canvas-Element rendert nie etwas sichtbar auf den Bildschirm.

Für diesen Anwendungsfall ist Hardwarebeschleunigung tatsächlich eher Fluch als Segen. Die Benutzung von getImageData() und toBlob() führt dazu, dass bei aktivierter Hardwarebeschleunigung Daten häufig zwischen CPU und GPU hin- und hergeschoben werden müssen, was (je nach Workload) den Vorteil der flotteren GPU zunichtemacht. Damit das nicht sein muss, gibt es mit der Option willReadFrequently ein Opt-Out aus der Hardwarebeschleunigung für 2D-Canvas:

// 2D-API mit Optimierung für Readbacks
const ctx = canvas.getContext("2d", {
  willReadFrequently: true,
});

Der relevante normative Teil der HTML-Spezifikation nennt den Effekt von willReadFrequently nicht direkt ein „ein Opt-Out aus der Hardwarebeschleunigung“, sondern definiert den Effekt als:

When a CanvasRenderingContext2D object's willReadFrequently is true, the user agent may optimize the canvas for readback operations.

Die direkt auf den obigen Satz folgende nicht-normative Anmerkung sowie auch die MDN-Doku zu getContext() nennen das Kind aber beim Namen: willReadFrequently aktiviert Software-Rendering, damit Readbacks schneller werden … und vielleicht auch, damit lästige Rendering-Bugs mit PDF.js verschwinden?

Canvas-Hardwarebeschleunigung per JavaScript selektiv deaktivieren

Es ist nicht die Aufgabe von PDF.js, Rendering-Bugs in Chrome und Chrome-Derivaten zu reparieren. Deshalb ist es gut und richtig, dass in der Library alle Aufrufe von getContext() ohne willReadFrequently daherkommen - der Job von PDF.js ist das zügige Rendern von PDFs und Hardwarebeschleunigung hilft dabei. Aus der Sicht meines Kunden (speziell aus der Sicht der für die Webapp zuständigen Abteilung) ist das freilich anders: Ihr Job ist es, fehlerfrei PDFs in Edge anzuzeigen - und zwar jetzt, nicht erst, wenn Browserbugs gefixt und ausgerollt sind.

Statt PDF.js zu patchen und damit zukünftige Updates der Library zu verunmöglichen, habe ich folgende, dezent haarsträubende Lösung vorgeschlagen:

(function() {
  "use strict";
  const originalGetContext = HTMLCanvasElement.prototype.getContext;
  function patchedGetContext(contextType, contextAttributes = {}) {
    return originalGetContext.call(
      this,
      contextType,
      { ...contextAttributes, willReadFrequently: true }
    );
  }
  HTMLCanvasElement.prototype.getContext = patchedGetContext;
})();

Dieser Code (ummantelt mit einer legacy-konformen IIFE nach Art der Vorväter) überschreibt kurzerhand die getContext()-Methode aller Canvas-Elemente mit einer Variante, die willReadFrequently immer auf true setzt.

Eigentlich versteht sich von selbst, dass man nicht auf diese Weise Prototypen von irgendwelchen Objekten patchen sollte:

Aber keine Regel ohne Ausnahme! Ich würde behaupten, dass der obige Code für das akut bestehende PDF-Render-Problem der am wenigsten schlechte Workaround ist, denn:

  • das betroffene Modul der betroffenen Webapp lebt aus einer Reihe von Gründen in einem Iframe. Dieser bildet eine eigene kleine Sandbox, weswegen nicht alle Canvas-Elemente im kompletten Projekt betroffen sind. Das Team mit dem PDF-Problem verändert nur jene Canvas-Elemente, die es auch verantwortet (wenn auch von diesen Canvas-Elementen tatsächlich alle).
  • selbst wenn alle Canvas-Elemente im Projekt betroffen wären, ist aktuell das schlimmste, was passieren könnte, nachlassende Performance beim Rendering. Langsam, korrekt gerenderte Webapps sind aber besser als schnell falsch gerenderte Webapps, also ist das ein akzeptabler Tradeoff! Zwar könnten zukünftige Änderungen an Standards und Browsern zu Problemen führen, aber ...
  • ...der Code ist für leichtes Löschen optimiert. Er befindet sich in einer Extra-Datei mit einem erklärenden Kommentar inkl. Datumsangabe und Verweis auf den Browser-Bug, der die eigentliche Ursache des Problems ist. Wer auch immer über den Code stolpert, sei es aus Zufall oder auf der Suche nach einem zukünftig entstehenden Problem, kann sofort entscheiden, ob das Modul gelöscht werden kann oder unter welchen Voraussetzungen nach einem neuen Workaround gesucht werden muss.

Für die gegebenen Umstände, unter denen ein akutes Problem schnell und ökonomisch aus der Welt zu schaffen war, halte ich den Code für die perfekt passende Lösung.

Fazit und Ausblick

Nach dem Ausrollen des gezeigten Workarounds ist das PDF-Anzeige-Proble meines Kunden behoben und die Webapp funktioniert, ohne dass die Browser der Nutzer speziell konfiguriert werden müssen. Eine echte Lösung des Problems bestünde in Änderungen an der Rendering-Engine der Browser oder ein Update aller im Unternehmen verwendeten PDFs – beides ist, wenn überhaupt möglich, nicht der Job des Webapp-Teams. Also bleiben nur unglamouröse Workarounds wie das Verändern von PDF.js oder des Canvas-Prototypen. Bei letzterem besteht zumindest eine gute Chance, dass zwischen Einbau und Löschung des Workarounds keine weiteren Probleme auftauchen. Und für den Fall dass doch, sind die Gründe für das Vorhandensein des Workarounds, seine etwas obskure Funktionsweise und die Bedingungen für seine Löschung (inkl. Verweis auf die relevanten Issues in den Bugtrackern der Browser) in Kommentaren lang und breit dokumentiert.

Für mich persönlich war es sehr schön, mal wieder das eingestaubte Wissen rund um Prototypen, this und Function.prototype.call zur Anwendung bringen zu können. In einer Welt, in der katastrophale Browser-Inkonsistenzen selten geworden sind und in der JS-OOP über Klassen extrem einfach geworden ist, kommt man kaum noch dazu, handgeknotete Prototyp-Ketten und Funktionsverbiegungen zu jonglieren. Was zwar alles in allem ein echter und unbestreitbarer Fortschritt ist, aber irgendwo auch ein klein wenig langweilt.

Die famose Fail-Funktion!

Veröffentlicht am 4. Januar 2023

In der Programmierung sind es immer die kleinen Dinge, die einen entweder in die Schreibtischplatte beißen lassen oder das Coding sehr viel angenehmer machen. In die zweite Kategorie fällt für mich die einfache, aber effektive Fail-Funktion. Diese schleppe ich schon seit Jahren mit mir herum und verwende sie in praktisch jedem Projekt oder Experiment, das mehr als 20 Zeilen JavaScript enthält.

Die Fail-Funktion macht nicht viel, leistet aber einiges. Eigentlich besteht sie nur aus drei Zeilen:

function fail(reason, ErrorConstructor = Error) {
  throw new ErrorConstructor(reason);
}

In Worten: die Fail-Funktion wirft einen Fehler! Mit dem optionalen ersten Parameter kann die Fehlermeldung angepasst werden (wird der Parameter ausgelassen, ist die Meldung ein leerer String), der optionale zweite Parameter erlaubt die Wahl des Fehler-Typs. Wenn wir etwas anderes als einen herkömmlichen Error haben wollen, können wir als zweiten Parameter einen alternativen Error-Typ wie z.B. TypeError angeben. Mehr passiert in dieser Funktion nicht!

Wann immer ich die Fail-Funktion anpreise, ernte ich zunächst Unverständnis: warum sollten wir eine Funktion schreiben (oder uns als Dependency ans Bein binden), die nichts tut, als einen Fehler zu werfen? Kann man nicht einfach direkt den Fehler auslösen? Was bringt uns der fail()-Wrapper?

Warum eine Fail-Funktion?

Tatsächlich können wir in JavaScript Stand 2022 nicht „einfach direkt den Fehler werfen“ - zumindest nicht in allen Fällen. Aufrufe der Fail-Funktion können nämlich an Stellen im Programm vorkommen, an denen kein throw-Statement stehen darf. Ein Beispiel zur Verdeutlichung:

let [value] = [];
if (typeof value === "undefined") {
  throw new Error("Got no value!");
}

Dieses Destructuring Assignment soll einen Wert aus einem iterierbaren Objekt (hier einem Array) extrahieren und falls es keinen Wert zum Herausziehen gibt, soll es einen Fehler geben. Mit der Fail-Funktion lässt sich das Gleiche in viel kürzer ausdrücken:

let [value = fail("Got no value!")] = []; // Error: Got no value!

Für Destructuring Assignments können wir in JavaScript seit Anbeginn der Zeiten Default-Werte angeben und es greift hier lazy evaluation: Die JavaScript-Engine schaut sich die rechte Seite des Gleichheitszeichens nur an, wenn sie auf der linken Seite ein undefined vorfindet. Normalerweise ist die Idee, der linken Seite einen Standard-Wert mitzugeben ...

let [value = 42] = []; // value === 42

... damit value niemals leer ausgeht. Doch wir können stattdessen, wenn wir keinen Default-Wert vergeben wollen, mithilfe der Fail-Funktion über den gleichen Mechanismus einen Fehler auslösen! Auf diese Weise können wir kompakt und bequem erzwingen, dass das Array immer mindestens einen Wert enthält (und dieser Wert in der Variable value landet).

Die Fail-Funktion kann diese Aufgabe übernehmen, weil ein Funktionsaufruf ein Ausdruck ist, während throw ein Statement ist (ersteres produziert einen Wert, zweiteres nicht; siehe Dr. Axels Erklärung zum genauen Unterschied). Und ein Ausdruck darf in JS-Programmen an vielen Stellen stehen, an denen ein Statement nichts verloren hat. Der folgende Code versucht, mittels eines direkten throw-Statements das Gleiche wie die Fail-Funktion zu erreichen, ist aber Stand 2022 syntaktisch nicht zulässig:

// SyntaxError
let [value = throw new Error("Got no value!")] = [];

Der Wert der Fail-Funktion besteht also kurz gesagt darin, dass sie ein Statement in einen Ausdruck verpackt und damit syntaktisch legalisiert, was normalerweise nicht erlaubt ist.

Der universeller Happy-Path-Eingrenzer

Die Preisfrage bei dem obigen Destructuring-Beispiel ist natürlich: hilft uns die Fail-Funktion an dieser Stelle, oder macht sie einen einfachen Programmablauf (wenn kein Wert, dann ein Fehler) im Vergleich zu einem If-Statement nicht eher kryptisch und übermäßig kompakt? Ich würde das mit Vehemenz bestreiten! Zahlreiche Szenarien sind mit der Fail-Funktion viel bequemer auszudrücken als auf jede andere Weise.

Szenario 1: eine Funktion mit drei Pflichtparametern:

function requiresThreeArguments(a, b, c) {}

Wie können wir sicherstellen, dass die Funktion auch tatsächlich mit allen drei Parametern aufgerufen wurde? In JavaScript hält uns im Prinzip nichts davon ab, beliebige Funktionen mit beliebig vielen beliebigen Parametern aufzurufen - ein Parameter wird erst dann zu einer Pflichtangabe, wenn wir ihn in der Funktion entsprechend überprüfen. Und das könnten wir über diverse Permutationen von If-Abfragen bewerkstelligen:

function requiresThreeArguments(a, b, c) {
  if (typeof a === "undefined") {
    throw new Error("a is required");
  }
  if (typeof b === "undefined") {
    throw new Error("b is required");
  }
  if (typeof c === "undefined") {
    throw new Error("c is required");
  }
}

Wenn wir der Meinung sind, auf sinnvolle Fehlermeldungen verzichten zu können (warum sollte man auch wissen wollen, welcher Parameter fehlt?), können wir die If-Kaskade auf ein einziges Statement eindampfen:

function requiresThreeArguments(a, b, c) {
  if (typeof a === "undefined" || typeof b === "undefined" || typeof c === "undefined") {
    throw new Error("something is missing");
  }
}

Besonders brillant finde ich weder die erste, noch die zweite Variante. Entweder ist es mir deutlich zu viel Code (Variante 1) oder die Fehlermeldungen sind zu unspezifisch (Variante 2). Und eigentlich ist mir auch Variante 2 zu viel Code! Ich möchte einfach nur - ohne zu TypeScript greifen zu müssen - eine Annotation an den Funktionsparametern haben, statt den Funktionsblock mit zusätzlichem Code zu verlängern. Aber zum Glück haben wir ja die Fail-Funktion!

function requiresThreeArguments(
  a = fail("a is required"),
  b = fail("b is required"),
  c = fail("c is required")
) {}

Das ist nicht nur in den meisten Fällen einfach kürzer, sondern bereinigt vor allem den Funktionsblock! Um das ganze noch etwas zu perfektionieren, könnten wir von fail() eine Variante ableiten, die einen schöneren Namen hat und standardmäßig einen passenderen TypeError durch die Gegend wirft:

const required = (reason) => fail(reason, TypeError);

function requiresThreeArguments(
  a = required("a is required"),
  b = required("b is required"),
  c = required("c is required")
) {}

Kompakt, lesbar und mit minimalen JavaScript-Bordmitteln umgesetzt, was will man mehr?

Das Konzept lässt sich auch bequem auf Objekte aller Art anwenden:

// Im Destructuring Assignment
let [value = fail("Got no value!")] = someIterableObject;

// Bei normalem Objektzugriff in Kombination mit ??
let value = someObject.value ?? fail("Got no value!")];

// Bei Maps in Kombination mit ??
let value = someMap.get("key") ?? fail("Got no value!")];

Gerade wenn wir doch mal in TypeScript unterwegs sind, ist die Fail Funktion in Kombination mit dem Nullish coalescing operator (??) Gold wert.

Die Fail-Funktion für TypeScript

Mit TypeScript-Typannotationen sieht die Funktion wie folgt aus:

export function fail(reason?: string, ErrorConstructor = Error): never {
  throw new ErrorConstructor(reason);
}

Der Rückgabetyp never der Funktion ist dabei der Schlüssel für Type Narrowing. Wird eine Funktion mit Rückgabetyp never aufgerufen, weiß TypeScript, dass das Programm in Folge nicht mehr weitergeht. Unter anderem wäre das der Fall, wenn eine Endlosschleife betreten wird oder wenn ein Fehler geworfen wird. Und wenn die Frage, ob das Programm weiter geht oder nicht, mit dem Typ einer bestimmten Variable zusammenhängt, dann ...

declare var myFoo: { value: number | undefined };

let val1 = myFoo.value;
// Typ von val1: number | undefined

let val2 = myFoo.value ?? fail();
// Typ von val2: number

Der Typ von val2 ist number, da fail(), wenn aufgerufen, zum Ende Programms führt - und das passiert nur, wenn myFoo.value entweder null oder undefined ist. Ist myFoo.value etwas anderes, wird die rechte Seite von ?? nicht ausgeführt und der Nachweis ist erbracht, dass val2, vom Typ number sein muss. Andernfalls würde das Programm (oder zumindest die aktuelle Funktion) per Error abrupt enden, was TypeScript problemlos nachvollzieht. Type Narrowing in Aktion!

Das ist nützlich, da das strikte Typsystem manche Programmabläufe nicht nachvollziehen kann und deshalb manchmal sehr vorsichtig mit der Typvergabe ist. Ein absolutes Extrembeispiel:

let map = new Map<string, number>();
map.set("key", 42);
let result = map.get("key");
// Typ von result: number | undefined

Es gibt keine Macht auf diesem Planeten, die verhindern kann, dass in diesem Beispiel result am Ende des Tages 42 enthält. Der Programmablauf kann unter keinen Umständen dazu führen, dass am Ende für map.get("key") ein undefined herauskommt, aber das ist nur klar, wenn wir alle drei Zeilen auf einmal betrachten und wissen, dass zwischendurch nichts anderes mit der Map passiert. Wir könnten natürlich eine Zeile einfügen, die das Undefined-Risiko wieder heraufbeschwört ...

let map = new Map<string, number>();
map.set("key", 42);
if (Math.random() < 0.1) { // Yolo
  map.delete("key");
}
let result = map.get("key");
// Typ von result: number | undefined

... aber wenn wir das nicht machen, wissen wir, dass result eine Zahl enthalten wird und TypeScript übervorsichtiges number | undefined steht uns im Weg herum. Was tun? Fail-Funktion benutzen!

let map = new Map<string, number>();
map.set("key", 42);
let result = map.get("key") ?? fail();
// Typ von result: number

Die Fail-Funktion erreicht an dieser Stelle zwei Dinge. Zum einen betreibt sie Type Narrowing. Dank der never-Rückgabetyp-Annotation der Fail-Funktion weiß TypeScript, dass das Programm endet, wenn sie aufgerufen wird und da das nur passiert, wenn auf der linken Seite von ?? entweder null oder undefined steht, weiß TS, dass wenn das Programm nicht endet, in result weder null noch undefined stehen können. Aus dem eigentlichen number | undefined, das wir aus get() bekommen, wird also number. Und sollte, auf welche Weise auch immer, für result wirklich einmal keine Zahl herauskommen, gibt es einen Fehler, der uns sofort zur verantwortlichen Zeile führt. Auf diese Weise führt die Fail-Funktion zu einem Zugewinn an Sicherheit (verglichen mit einer Type-Assertion as number) und, per Type Narrowing zu einer Verbesserung der Ergonomie. Win-Win!

Fazit und Ausblick

Obwohl die Fail-Funktion nur drei Zeilen hat, leistet sie viel: Code wird kompakter, sicherer und (im Kontext von TypeScript) sehr viel weniger lästig. Ist die Fail-Funktion also uneingeschränkt großartig und sollte von uns allen stets und ständig verwendet werden? Stand jetzt schon, aber sie könnte in Zukunft überflüssig werden.

Das Einzige, was noch besser als die Fail-Funktion wäre, wäre wenn wir ohne eine Extra-Funktion Fehler an Ausdrucks-Positionen werfen könnten und TC39 arbeitet tatsächlich an einem entsprechenden Feature! Die neue throw-Expression ließe sich genau so verwenden wie die Fail-Funktion, wäre aber ein neues, natives Feature:

function requiresThreeArguments(
  a = throw new TypeError("a is required"),
  b = throw new TypeError("b is required"),
  c = throw new TypeError("c is required")
) {}

throw in diesem Kontext sieht genau so aus, wie ein throw-Statement, ist aber ein Ausdruck und daher technisch gesehen etwas anderes. Benutzen ließe sich aber beides auf die gleiche Weise. Throw-Expressions hätten diverse kleine Vorteile gegenüber der Fail-Funktion und würden nur überschaubare Anpassungen an der ECMAScript-Grammatik benötigen. Da das Proposal aber nun schon seit Jahren im Limbo zwischen Stage 2 und 3 herumeiert, werden wir bis auf Weiteres der Fail-Funktion bleiben müssen.

How to Center in CSS, Scrollbar Edition

Veröffentlicht am 30. November 2022

Der älteste Witz des bekannten Webdev-Universums besteht darin, dass es nicht komplett trivial ist, Elemente mit CSS vertikal zu zentrieren. Dieser Witz ist spätestens seit Frühjahr 2021, als mit dem Internet Explorer 11 der letzte nicht perfekt Flexbox unterstützende Browser auf das Abstellgleis befördert wurde, hinfällig. Seither, so wissen wir alle, könnte es einfacher nicht sein:

.wrapper {
  display: flex;
  align-items: center;
}

Flexbox (und Grid) bieten direkte Unterstützung für vertikale Zentrierung und das Problem ist damit ein für allemal komplett erschlagen. Außer per Flexbox und Grid ist Zentrierung außerdem mit absoluter Positionierung und einer Transformation zu erreichen. Dieses Verfahren braucht ein paar Zeilen mehr, ist dafür aber auf dem zu zentrierenden Element statt auf dessen Container anzuwenden:

.wrapper {
  position: relative;
}

.element {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
}

Diese Variante, die sich zunutze macht, dass sich Prozentangaben bei Transformationen auf die Maße des Elements statt auf die des Containers beziehen, hat den Vorteil, dass durch die Angabe von left und translateX en passant auch eine horizontale Zentrierung zu machen ist. Beide Varianten funktionieren ganz ausgezeichnet … solange das zu zentrierende Element kleiner (für vertikale Zentrierung: weniger hoch) ist, als sein Elternelement. Aber was, wenn nicht?

Vertikale Zentrierung schlägt Scrollbalken

Nehmen wir einmal an, wir wollten ein Element variabler Größe innerhalb eines Containers darstellen. Das Element soll in unserem Beispiel vertikal zentriert sein, solange dafür der Platz ausreicht und wenn der Container für sein Element zu klein ist, sollen Scrollbalken auf dem Container die Möglichkeit bieten, das komplette Element zu betrachten. Auf diese Weise könnte etwa ein Grafikprogramm, bei kleinen Zoomstufen die gesamte Malfläche darstellen und bei starker Vergrößerung vertikales und/oder horizontales Scrollen ermöglichen.

Das klingt zunächst ganz einfach, Scrollbalken in Webseiten sind schließlich kein Hexenwerk: Wir müssen nur dem Container overflow:auto verpassen, und schon taucht (wenn nötig) das gewünschte Scroll-UI auf. Problem gelöst! Es sei denn, wir wollen scrollen und vertikal zentrieren:

Links ein vertikal zentriertes Element, das oben und unten aus seinem kleineren Container herausläuft. Rechts hat der Container scollbalken, doch der oben überstehende Teil des Kindelements ist trotzdem abgeschnitten.

Das sieht gar nicht mal so gut aus! Die horizontale CSS-Zentrierung ist offenbar unverwüstlich und zentriert auch das, was eigentlich nicht mehr in seinen Container passt. Die Mitte des Containers und die Mitte des zu zentrierenden Elements (die Rot-Blau-Grenze) liegen aufeinander und wenn der Container weniger hoch als sein Inhalt ist, läuft es oben und unten über die Grenze seines Elternelements. So weit, so erwartbar. Der mit overflow: auto hinzugefügte Scrollbalken macht aber nur den unteren über den Rand laufenden Teil des zu großen Elements erschließbar! Alles, was oben übersteht, der gesamte überstehende rote Anteil, wird einfach abgeschnitten und ist komplett unerreichbar (man achte auf die Scroll-Position des rechten Containers im obigen Bild). Möglicherweise wichtiger Inhalt verschwindet auf diese Weise komplett im Overflow-Orkus. Das kann so nicht bleiben!

Eine Formel für bedingte vertikale Zentrierung … mit Nachteilen

Um unser Ursprungsziel zu erreichen, benötigen wir im Prinzip eine bedingte vertikale Zentrierung, die nur bei ausreichend Platz greift. Sobald der zu zentrierende Inhalt zu groß für seinen Container wird, muss der Zentrierungsmechanismus seine Arbeit einstellen und das Element normal an die obere linke Ecke seines Containers andocken, damit der Scrollbalken seine Arbeit verrichten kann. Am einfachsten geht das, indem wir

  1. Anstelle von Flexbox oder Grid als Zentrierungsmechanismus position:absolute wählen
  2. top mithilfe von calc() errechnen, statt einfach top:50% zu verwenden und für die Maße des Elements mit transform: translateY(-50%) zu kompensieren
  3. Einen Top-Overflow unterbinden, indem wir das calc()-Ergebnis aus Schritt 2 mithilfe von max() auf mindestens 0px beschränken

Im Endeffekt:

.element {
  top: max(0px, calc((100% - 600px) / 2));
}
Links die schlechte Scroll-Lösung aus dem vorherigen Beispiel, rechts die bessere Lösung nach der neuen Formel.

Der erste Parameter für max() ist der für uns nicht zu unterschreitende Top-Offset von 0px, während der zweite Parameter unser eigentlicher Wunsch-Offset ist. Für vertikale Zentrierung ist das die Hälfte des Platzes, der übrig bleibt, wenn wir von der Höhe des Containers (100%) die Höhe des zu zentrierenden Elements (im Beispiel 600px) abziehen. Das Ergebnis des calc()-Ausdrucks wird negativ, wenn des zu zentrierende Element höher als sein Container ist, aber vor diesen Auswirkungen bewahrt uns die Limitierung auf mindestens 0px. Im schlimmsten Fall, bei zu wenig Platz, dockt das Element also oben an und ist auf ganzer Höhe erscrollbar.

Der Haken an dieser Lösung: die Maße des zu zentrierenden Elements müssen bekannt sein. Prozentangaben in top beziehen sich auf das Elternelement und können sich auch auf nichts anderes beziehen; durch die Angaben wie top (ggf. kombiniert mit left, bottom und right) ergeben sich die Maße des Elements schließlich erst. Die Höhe des bedingt zu zentrierenden Elements müssen wir also kennen und in die „Formel“ für top eintragen.

Grundsätzlich könnten wir die top-Formel auch in der transform-Eigenschaft verwenden, haben dort dann aber das umgekehrte Problem: Wir müssten die Dimensionen des Elternelements kennen und fest eintragen. Prozentangaben in transform-Werten beziehen sich auf das betroffene Element selbst, denn eine Transformation ist nur eine Veränderung des Koordinatensystems für die zu zeichnenden Pixel. Deshalb bleibt der „Platzverbrauch“ eines Elements vor und nach einer Transformation gleich; die Pixel sind transformiert, doch das CSS-Layout bleibt, wie es war:

Ein per CSS transformiertes, von CSS umflossenes Quadrat demonstriert, dass nur das Rendering eines Elements verändert wird, das eigentliche Layout jedoch nicht.

Egal ob wir die Verschiebung des Elements per Transformation oder per top versuchen, komplette Flexibilität ist nicht drin   entweder, die Maße des Containers oder des zu zentrierenden Kindelements müssen wir kennen. Es sei denn, wir verwenden ein ganz bestimmtes Feature aus dem Dunstkreis der nagelneuen Container Queries!

Eine flexiblere Formel für bedingte vertikale Zentrierung

Container Queries sind im Prinzip Media Queries für Elemente – Queries, die sich nicht auf der Maße des Bildschirms, sondern auf die Eigenschaften ausgewählter Elternelemente (der Query Container) beziehen. Container Queries sind aber nicht einfach nur ein Standard für besseres Responsive Design, sondern führen auch eine Reihe von interessanten neuen Einheiten ein:

  • cqw: 1% der Breite eines Query Containers
  • cqh: 1% der Höhe eines Query Containers
  • cqi/cqb: 1% Der Inline- bzw. Block-Größe eines Query Containers
  • cqmin/cqmax: Der kleinere bzw. größere Wert von cqi und cqb

Das bedeutet: in einer CSS-Transformation eines Elements haben wir, wenn sein Elternelement ein Query-Container ist, sowohl die Maße des Elements selbst (als %) als auch die des Elternelements (als cqw bzw. cqh) zur Hand! Die top-Formel, entsprechend adaptiert und in transform verwendet, kann damit das Element in eine zentrierte Position verschieben. Das Elternelement muss nur noch zum Query-Container erklärt werden (damit sich cqw und cqh auf etwas beziehen können) und schon klappt es:

.wrapper {
  container-type: size; /* Maße von .wrapper definieren cqh/cqw */
  overflow: auto;
}
.wrapper .element {
  transform: translateY(max(0%, calc((100cqh - 100%) / 2)));
}

Selbst wenn sich die Maße von Container oder Content ändern, das Element bleibt zentriert und die Scrollbalken greifen, wenn nötig, ein! Besonders charmant finde ich an dieser Lösung den Grund für das Erscheinen und Verschwinden der Scrollbalken. Das Element wird zwar zentriert, doch da dies per Transformation geschieht, manifestiert sich der „Platzverbrauch“ des zentrierten Elements durchgehend an der linken oberen Ecke. Wird das Elternelement zu klein, findet sich auch die Transformation der Darstellung an dieser Position ein und - da ab dann der Platzverbrauch auch größer als der umgebende Container ist - die Scrollbalken tauchen auf. Keine Tricks jenseits der Transformation notwendig!

Fazit, Nachteile und Browserunterstützung

Wo sind die Haken bei dieser Lösung? Container Queries fehlen stand Dezember 2022 noch im Firefox. Support im Nightly Build ist schon da, aber noch nicht im regulären Release. Die Transformation bildet natürlich einen neuen Stacking Context und die Angabe von container-type auf dem Wrapper-Element aktiviert implizites Layout-, Style-, und Size-Containment. Das alles, abgesehen vom Firefox-Support, sind keine echten Haken, sondern fallen eher in die Kategorie der zu beachtenden Dinge. Ein reiner Selbstläufer ist das vertikale Zentrieren mit CSS dann doch nicht, jedenfalls nicht, wenn Scrollbalken ins Spiel kommen. Der älteste Witz des bekannten Web-Universums ist und bleibt auch Ende 2022 noch ein ganz klein wenig relevant.