Ein Physiker, ein Statiker und ein Programmierer fahren über einen steilen Alpenpass, als plötzlich die Bremsen versagen. Das Auto wird schneller und schneller. Es wird immer schwieriger, um die Kurven zu kommen. Ein-, zweimal verhindert nur noch die Leitplanke, dass sie in den Abgrund stürzen. Sie werden sicher alle sterben. Plötzlich kommt eine Nothaltespur in Sicht. Sie schaffen es, und das Auto kommt gerade noch zum Stehen.
Der Physiker sagt: »Wir brauchen ein Modell der Reibung in den Bremsscheiben und des resultierenden Temperaturanstiegs, um herauszufinden, was passiert ist.«
Der Statiker sagt: »Ich glaube, wir haben etwas Werkzeug im Kofferraum. Ich sehe mal nach, ob ich herausfinden kann, was nicht stimmt.«
Der Programmierer sagt: »Warum versuchen wir nicht, das Problem zu reproduzieren?«
– Anonym
TypeScript tut alles, um Laufzeitfehler bereits in der Kompilierungsphase abzufangen: Vom umfangreichen Typsystem bis hin zu mächtigen statischen und symbolischen Analysen setzt TypeScript alles in seiner Macht stehende ein, um zu verhindern, dass Sie den Freitagabend wieder einmal mit dem Debuggen falsch geschriebener Variablennamen und Null-Pointer-Exceptions verbringen müssen (und Ihr Mitarbeiter auf Bereitschaft doch noch rechtzeitig zum Geburtstag seiner Tante kommt).
Leider schaffen es Laufzeitfehler, unabhängig von der verwendeten Sprache, aber immer wieder, sich trotzdem einzuschleichen. TypeScript tut, was es kann. Netzwerk- und Dateisystemfehler, Probleme beim Parsen von Benutzereingaben, Stacküberläufe und Speicherfehler kann es aber auch nicht verhindern. Dank des üppigen Typsystems gibt es allerdings eine Vielzahl von Möglichkeiten, mit Laufzeitfehlern umzugehen, die trotz aller Vorsicht durchkommen.
In diesem Kapitel zeige ich Ihnen die gängigsten Muster für die Darstellung und Behandlung von Fehlern in TypeScript:
Welchen Weg Sie tatsächlich wählen, liegt bei Ihnen und hängt von Ihrer Applikation ab. Ich werde zu jedem Weg die jeweiligen Vor- und Nachteile nennen, damit Sie selbst die richtigen Entscheidungen treffen können.
Unser Beispielprogramm fragt den Benutzer nach seinem Geburtsdatum, das wir in ein Date-Objekt umwandeln wollen:
function ask() {
return prompt('When is your birthday?')
}
function parse(birthday: string): Date {
return new Date(birthday)
}
let date = parse(ask())
console.info('Date is', date.toISOString())
Das vom Benutzer eingegebene Datum sollten wir vermutlich validieren. Schließlich ist es nur eine einfache Texteingabe:
// ...
function parse(birthday: string): Date | null {
let date = new Date(birthday)
if (!isValid(date)) {
return null
}
return date
}
// Überprüft, ob das eingegebene Datum gültig ist
function isValid(date: Date) {
return Object.prototype.toString.call(date) === '[object Date]'
&& !Number.isNaN(date.getTime())
}
Wenn wir diesen Code verwenden, sind wir gezwungen, vor der Verwendung zu testen, ob das Ergebnis null ist:
// ...
let date = parse(ask())
if (date) {
console.info('Date is', date.toISOString())
} else {
console.error('Error parsing date for some reason')
}
Die Rückgabe von null ist der einfachste Weg, typsicher mit Fehlern umzugehen. Gültige Benutzereingaben ergeben ein Date-Objekt, ungültige Eingaben ergeben null. Dabei überprüft das Typsystem, ob wir auch wirklich beide Fälle behandeln.
Bei dieser Vorgehensweise verlieren wir allerdings einige Informationen, weil parse nicht mitteilt, warum genau die Operation fehlgeschlagen ist. Das ärgert nicht nur die Person, die Ihre Logs durchkämmen muss (meistens Sie selbst), sondern auch den Benutzer, der nur den Fehler erhält: »Konnte das Datum aus irgendeinem Grund nicht parsen« (»Error parsing date for some reason«). Eine verständliche und aussagekräftige Meldung wäre hier deutlich besser, zum Beispiel: »Geben Sie das Datum in der Form YYYY/MM/DD ein.«
Die Rückgabe von null ist außerdem schwer zu konstruieren: Sobald Sie beginnen, Operationen zu verschachteln und zu verketten, führt der Test auf null nach jeder Operation schnell zu unübersichtlichem Code.
Anstelle der Rückgabe von null wollen wir eine Ausnahme auslösen. So können wir auf die verschiedenen Ursachen besser eingehen und erhalten außerdem zusätzliche Metadaten über das Problem, dass wir dadurch leichter debuggen können.
// ...
function parse(birthday: string): Date {
let date = new Date(birthday)
if (!isValid(date)) {
throw new RangeError('Enter a date in the form YYYY/MM/DD')
}
return date
}
Bei der Verwendung dieses Codes müssen wir darauf achten, den Fehler abzufangen, um ihn sauber behandeln zu können, ohne dass unsere Applikation gleich abstürzt:
// ...
try {
let date = parse(ask())
console.info('Date is', date.toISOString())
} catch (e) {
console.error(e.message)
}
Vermutlich sollen andere Ausnahmen erneut ausgelöst werden, damit wir nicht aus Versehen jeden möglichen Fehler verschlucken:
// ...
try {
let date = parse(ask())
console.info('Date is', date.toISOString())
} catch (e) {
if (e instanceof RangeError) {
} else {
throw e
}
}
Vielleicht sollen für den Fehler spezifischere Subklassen verwendet werden. Ändert ein Programmierer parse oder ask, sodass andere Ausnahmen vom Typ RangeError ausgelöst werden, können wir zwischen unserem Fehler und den anderen besser unterscheiden:
// ...
// Eigene Ausnahmetypen
class InvalidDateFormatError extends RangeError {}
class DateIsInTheFutureError extends RangeError {}
function parse(birthday: string): Date {
let date = new Date(birthday)
if (!isValid(date)) {
throw new InvalidDateFormatError('Enter a date in the form YYYY/MM/DD')
}
if (date.getTime() > Date.now()) {
throw new DateIsInTheFutureError('Are you a timelord?')
}
return date
}
try {
let date = parse(ask())
console.info('Date is', date.toISOString())
} catch (e) {
if (e instanceof InvalidDateFormatError) {
console.error(e.message)
} else if (e instanceof DateIsInTheFutureError) {
console.info(e.message)
} else {
throw e
}
}
Nicht schlecht. Zusätzlich dazu können wir eigene Ausnahmen nutzen, um zu zeigen, warum es schiefgegangen ist. Diese Fehler können bei der Fehlersuche in den Logdateien nützlich sein, aber auch, um unseren Benutzern aussagekräftige Rückmeldungen darüber zu geben, was sie falsch gemacht haben und wie sie das Problem beheben können. Wir können Operationen effektiv verschachteln und verketten, indem wir sie in einem gemeinsamen try/catch-Block zusammenfassen (dabei müssen wir nicht jede einzelne Operation auf Fehler überprüfen wie bei der Rückgabe von null).
Angenommen, der try/catch-Teil befindet sich in einer Datei und der restliche Code liegt in einer Bibliothek, die von woanders importiert wird. Wie kann ein Programmierer in diesem Fall auf spezifische Fehler (InvalidDateFormatError und DateIsInTheFutureError) oder auch nur einen einfachen RangeError testen? (Bedenken Sie, dass Ausnahmen in TypeScript nicht als Teil der Funktionssignatur codiert werden.) Wir könnten in unserem Funktionsnamen darauf hinweisen (parseThrows) oder Informationen in einem Docblock1 hinzufügen:
/**
* @throws {InvalidDateFormatError} Benutzer hat Geburtsdatum falsch angegeben.
* @throws {DateIsInTheFutureError} Benutzer hat ein Geburtsdatum angegeben,
* das in der Zukunft liegt.
*/
function parse(birthday: string): Date {
// ...
In der Praxis würden Programmierer eher keinen try/catch-Block verwenden bzw. auf Ausnahmen testen, weil Programmierer faul sind (jedenfalls bin ich es). Das Typsystem informiert Sie nicht darüber, dass Sie einen Fall übersehen haben. Manchmal (wie hier) sind Fehler so wahrscheinlich, dass Downstream-Code sich auf jeden Fall darum kümmern sollte, um zu verhindern, dass das Programm abstürzt.
Können wir den Benutzern unseres Codes auch auf andere Weise mitteilen, dass sie nicht nur auf die erfolgreiche, sondern auch auf die fehlgeschlagene Ausführung eingehen müssen?
TypeScript ist nicht Java. Es unterstützt keine throws-Klauseln.2 Immerhin können wir etwas Ähnliches mit Vereinigungstypen erreichen:
// ...
function parse(
birthday: string
): Date | InvalidDateFormatError | DateIsInTheFutureError {
let date = new Date(birthday)
if (!isValid(date)) {
return new InvalidDateFormatError('Enter a date in the form YYYY/MM/DD')
}
if (date.getTime() > Date.now()) {
return new DateIsInTheFutureError('Are you a timelord?')
}
return date
}
Jetzt ist der aufrufende Code gezwungen, sich mit allen drei Fällen zu befassen – InvalidDateFormatError, DateIsInTheFutureError und dem erfolgreichen Parsen –, sonst wird bei der Kompilierung ein TypeError ausgelöst:
// ...
let result = parse(ask()) // Entweder ein Datum oder ein Fehler
if (result instanceof InvalidDateFormatError) {
console.error(result.message)
} else if (result instanceof DateIsInTheFutureError) {
console.info(result.message)
} else {
console.info('Date is', result.toISOString())
}
Hier haben wir das Typsystem von TypeScript erfolgreich eingesetzt, um:
Ein fauler Aufrufer kann die Fehlerbehandlung individuell vermeiden. Das muss allerdings explizit passieren:
// ...
let result = parse(ask()) // Entweder ein Datum oder ein Fehler
if (result instanceof Error) {
console.error(result.message)
} else {
console.info('Date is', result.toISOString())
}
Natürlich kann Ihr Programm immer noch durch Speicherfehler oder Stacküberläufe abstürzen. Diese lassen sich aber nur sehr begrenzt abfangen.
Dieser Ansatz erfordert wenig Aufwand und braucht keine speziellen Datenstrukturen. Trotzdem ist er so informativ, dass Aufrufer wissen, für welche Art von Fehler eine Ausnahme steht und wo man bei Bedarf weitere Informationen findet.
Der Nachteil ist, dass das Verketten und Verschachteln von Fehler auslösenden Operationen schnell unübersichtlich werden kann. Gibt eine Funktion T | Error zurück, so hat eine aufrufende Funktion zwei Möglichkeiten:
function x(): T | Error1 {
// ...
}
function y(): U | Error1 | Error2 {
if (a instanceof Error) {
return a
}
// Irgendwas mit a anstellen
}
function z(): U | Error1 | Error2 | Error3 {
let a = y()
if (a instanceof Error) {
return a
}
// Irgendwas mit a anstellen
}
Dieser Ansatz benötigt mehr Code. Gleichzeitig bietet er ausgezeichnete Sicherheit.
Ausnahmen können auch anhand spezieller Datentypen beschrieben werden. Im Vergleich zur Rückgabe von Vereinigungstypen aus Werten und Fehlern hat dieser Ansatz gewisse Nachteile (namentlich die Interoperabilität mit Code, der diese Datentypen nicht verwendet). Andererseits erhalten Sie die Möglichkeit, Operationen auch über möglicherweise fehlerhafte Berechnungen zu verketten. Die drei beliebtesten Optionen sind die Typen Try, Option und Either3. In diesem Kapitel kümmern wir uns allerdings nur um Option.4 Die anderen Optionen sind aber eng verwandt.
Im Gegensatz zu Array, Error, Map oder Promise sind die Datentypen Try, Option und Either nicht Teil der JavaScript-Umgebungen. Wenn Sie diese Typen verwenden wollen, müssen Sie die passenden Implementierungen im NPM finden oder selbst schreiben. |
Den Option-Typ kennen wir aus Sprachen wie Haskell, OCaml, Scala und Rust. Anstelle eines Werts wird ein Container zurückgegeben, der einen Wert enthalten kann oder nicht. Der Container besitzt verschiedene Methoden, mit denen Sie Operationen verketten können, selbst wenn er keinen Wert enthält. Dabei kann der Container aus einer beliebigen Datenstruktur bestehen, solange sie einen Wert enthalten kann. Im folgenden Beispiel benutzen wir ein Array als Container:
// ...
function parse(birthday: string): Date[] {
let date = new Date(birthday)
if (!isValid(date)) {
return []
}
}
let date = parse(ask())
date
.map(_ => _.toISOString())
.forEach(_ => console.info('Date is', _))
Vermutlich haben Sie gemerkt, dass ein Nachteil von Option darin besteht, dass er – so ähnlich wie bei der Rückgabe von null – dem Consumer nicht mitteilt, warum der Fehler aufgetreten ist. Er gibt nur an, dass etwas schiefgelaufen ist. |
Seine Stärken spielt Option aus, wenn mehrere Operationen hintereinander durchgeführt werden sollen, die alle fehlschlagen können.
Vorher sind wir davon ausgegangen, dass prompt immer erfolgreich ist und parse möglicherweise fehlschlagen kann. Was aber, wenn prompt nicht immer erfolgreich ist, beispielsweise weil der Benutzer die Eingabe abbricht. Auch das ist ein Fehler, nach dem unsere Berechnungen nicht fortgesetzt werden sollten. Um das darzustellen, brauchen wir ... noch eine Option!
function ask() {
let result = prompt('When is your birthday?')
if (result === null) {
return []
}
return [result]
}
// ...
ask()
.map(parse)
.map(date => date.toISOString())
// Error TS2339: Property 'toISOString' does not exist on type 'Date[]'.
.forEach(date => console.info('Date is', date))
Autsch! Das hat nicht funktioniert. Da wir ein Array mit Date-Objekten (Date[]) auf ein verschachteltes Array mit Date-Objekten abgebildet haben (Date[][]), müssen wir es auf ein einfaches Array reduzieren, bevor wir weitermachen können:
flatten(ask()
.map(parse))
.map(date => date.toISOString())
.forEach(date => console.info('Date is', date))
// Reduziert ein verschachteltes Array auf ein einfaches Array
function flatten<T>(array: T[][]): T[] {
return Array.prototype.concat.apply([], array)
}
Das wird schnell unhandlich. Da die Typen nicht besonders aussagekräftig sind (alles ist ein reguläres Array), ist es schwer, auf den ersten Blick zu sehen, was im Code passiert. Das können wir verbessern, indem wir einen speziellen Datentypen verwenden, der uns hilft, unseren Ansatz zu dokumentieren: Der Wert wird in einem Container gespeichert. Es gibt die Möglichkeit, diesen Wert zu verändern und ein Ergebnis vom Container zurückzuerhalten. Sobald die Implementierung fertig ist, sollten Sie den Datentyp folgendermaßen benutzen können:
ask()
.flatMap(parse)
.flatMap(date => new Some(date.toISOString()))
.flatMap(date => new Some('Date is ' + date))
.getOrElse('Error parsing date for some reason')
Wir definieren unseren Option-Typ wie folgt:
Abbildung 7-1: Option<T> hat zwei Zustände: Some<T> und None.
Skizzieren wir zunächst die Typen:
interface Option<T> {}
class Some<T> implements Option<T> {
constructor(private value: T) {}
}
class None implements Option<never> {}
Diese Typen sind gleichbedeutend mit folgender arraybasierter Implementierung von Option:
Was können Sie mit einer Option anstellen? Für unsere rudimentäre Implementierung definieren wir nur zwei Operationen:
Eine Möglichkeit, Operationen zu verketten, auch wenn Option möglicherweise leer ist
getOrElse
Eine Möglichkeit, einen Wert aus einer Option auszulesen
Wir beginnen mit der Definition dieser Operationen an unserem Option-Interface. Das heißt, Some<T> und None müssen hierfür konkrete Implementierungen bereitstellen.
interface Option<T> {
flatMap<U>(f: (value: T) => Option<U>): Option<U>
getOrElse(value: T): T
}
class Some<T> extends Option<T> {
constructor(private value: T) {}
}
class None extends Option<never> {}
Das bedeutet:
Basierend auf den Typen können wir diese Methoden jetzt für Some<T> und None implementieren:
interface Option<T> {
flatMap<U>(f: (value: T) => Option<U>): Option<U>
getOrElse(value: T): T
}
class Some<T> implements Option<T> {
constructor(private value: T) {}
flatMap<U>(f: (value: T) => Option<U>): Option<U> {
return f(this.value)
}
getOrElse(): T {
return this.value
}
}
class None implements Option<never> {
flatMap<U>(): Option<U> {
return this
}
getOrElse<U>(value: U): U {
return value
}
}
Wir können diese einfache Implementierung verbessern, indem wir unsere Typen besser spezifizieren. Wenn Sie nur wissen, dass es eine Option und eine Funktion gibt, die ein T auf eine Option<U> abbildet, dann resultiert der Aufruf von flatMap an einer Option<T> grundsätzlich in einer Option<U>. Wenn Sie dagegen wissen, dass Sie Some<T> oder None haben, können Sie etwas genauere Angaben machen:
Tabelle 7-1 zeigt, welche Ergebnistypen wir beim Aufruf von flatMap an den beiden Option-Typen erwarten:
Tabelle 7-1: Ergebnisse des Aufrufs von .flatMap(f) an Some<T> und None
|
Von Some<T> |
Von None |
Zu Some<U> |
Some<U> |
None |
Zu None |
None |
None |
Wir wissen, dass der Aufruf von flatMap an None immer ein None ergibt und dass der Aufruf an einem Some<T> entweder ein Some<T> oder ein None ergibt, abhängig davon, was der Aufruf von f zurückgibt. Das können wir ausnutzen. Wir verwenden überladene Signaturen, um flatMap mit spezifischeren Typen zu versehen:
interface Option<T> {
flatMap<U>(f: (value: T) => None): None
flatMap<U>(f: (value: T) => Option<U>): Option<U>
getOrElse(value: T): T
}
class Some<T> implements Option<T> {
constructor(private value: T) {}
flatMap<U>(f: (value: T) => None): None
flatMap<U>(f: (value: T) => Some<U>): Some<U>
flatMap<U>(f: (value: T) => Option<U>): Option<U> {
return f(this.value)
}
getOrElse(): T {
return this.value
}
}
class None implements Option<never> {
flatMap(): None {
return this
}
getOrElse<U>(value: U): U {
return value
}
}
Fast fertig. Jetzt müssen wir nur noch die Option-Funktion implementieren, mit der neue Optionen erzeugt werden. Den Typ Option haben wir bereits als Interface implementiert. Jetzt erstellen wir eine Funktion mit gleichem Namen (wie Sie wissen, hat TypeScript zwei getrennte Namensräume für Typen und Werte). Das Verfahren hat Ähnlichkeit mit der Vorgehensweise aus »Das Companion-Objektmuster« auf Seite 141. Übergibt ein Benutzer null oder undefined, ist der Rückgabewert ein None, ansonsten geben wir ein Some zurück. Auch hier überladen wir die Signatur, um das Ziel zu erreichen:
function Option<T>(value: null | undefined): None
function Option<T>(value: T): Some<T>
function Option<T>(value: T): Option<T> {
if (value == null) {
return new None
}
return new Some(value)
}
Das ist auch schon alles. Wir haben einen voll funktionsfähigen, minimalen Option-Typ abgeleitet, mit dem wir auch an Werten, die vielleicht null sind, sichere Operationen ausführen können. Die Verwendung sieht so aus:
let result = Option(6) // Some<number>
.flatMap(n => Option(n * 3)) // Some<number>
.flatMap(n => new None) // None
.getOrElse(7) // 7
Jetzt funktioniert unsere Geburtstags-Eingabe wie erwartet:
ask() // Option<string>
.flatMap(parse) // Option<Date>
.flatMap(date => new Some(date.toISOString())) // Option<string>
.flatMap(date => new Some('Date is ' + date)) // Option<string>
.getOrElse('Error parsing date for some reason') // string
Optionen sind eine gute Möglichkeit, mit mehreren Operationen zu arbeiten, die möglicherweise fehlschlagen können. Sie bieten ausgezeichnete Typsicherheit und signalisieren dem Consumer über das Typsystem, dass eine bestimmte Operation möglicherweise fehlschlagen kann.
Trotzdem haben Optionen auch Nachteile. Das Fehlschlagen wird durch ein None signalisiert. Weitere Details dazu, was fehlgeschlagen ist und warum, gibt es nicht. Außerdem ist die Zusammenarbeit mit Code, der keine Optionen verwendet, nicht besonders gut (diese APIs benötigen einen expliziten Wrapper, um Optionen zurückgeben zu können).
Trotzdem kann dieses Vorgehen praktisch sein. Die hier verwendeten Überladungen lassen sich in den meisten anderen Sprachen nicht ausdrücken – nicht in solchen, die den Option-Typ für nullwertfähige Werte verwenden. Dadurch, dass wir Option durch überladene Aufrufsignaturen nach Möglichkeit auf Some und None beschränken, wird Ihr Code deutlich sicherer, und viele Haskell-Programmierer werden ziemlich neidisch. Zeit für ein kühles Blondes – Sie haben es sich verdient.
In diesem Kapitel haben wir die verschiedenen Möglichkeiten der Fehlerbehandlung betrachtet: die Rückgabe von null, das Auslösen von Ausnahmen und den Option-Typ. Damit besitzen Sie vielfältige Möglichkeiten, mit Dingen umzugehen, die fehlschlagen können. Welcher Ansatz für Sie der richtige ist, hängt von den folgenden Faktoren ab:
class API {
getLoggedInUserID(): UserID
getFriendIDs(userID: UserID): UserID[]
getUserName(userID: UserID): string
}