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.