Ein Blog

Aus der Reihe “Blogposts, die man kennen sollte”: Parse, don’t validate.

Wenn Ihr eine Anwendung baut, macht ungültige Zustände in Eurer Typdomäne nicht-repräsentierbar. Ihr müsst dann anschließend nichts mehr validieren, sondern nur noch parsen. Letzteres macht ggf. sogar ein Framework für Euch. Und Ihr zwingt Euch dazu, Fehlerfälle nicht zu übersehen.

Ein einfaches Beispiel in TypeScript:
Szenario: Ein Server kann zwei Antworten geben:

{ "ok": true, "data": "Bitteschön" }
{ "ok": false, "message": "Ich bin ein Kaffeepott" }

Was man nicht machen sollte, wäre folgendes DTO als Modellierung für die Antwort zu nehmen:

interface Response {
    ok: boolean;
    message?: string;
    data?: string;
}
const r = await fetch("...").then(r => r.json()) as Response;

Warum nicht?

  • Weil man andauernd prüfen muss, ob message vorhanden ist.
  • Weil es bei diesem DTO gültig ist, dass das Objekt weder message noch data hat. Dieser ungültige Zustand wäre in dieser Modellierung möglich!
  • Weil man vergessen könnte, auf ok zu überprüfen.
  • Kann bei Refactorings kaputt gehen.

Was könnte man stattdessen machen? TypeScript hat (wie andere Sprachen auch) discriminated/tagged Unions. Rust-Menschen kennen das als ihr Enum, nur dass das in TS auf JS-Objekten funktioniert. Dabei fungiert ein- oder mehrere gemeinsame Propertys der Typen als Discriminator (also “Unterscheider”).

Wir definieren genau die zwei Möglichkeiten, die uns der Server geben kann und sagen “das oder das”:

interface SuccessResponse {
    ok: true;
    data: string;
}
interface ErrorResponse {
    ok: false;
    message: string;
}
type Response = SuccessResponse | ErrorResponse;

const r = await fetch("...").then(r => r.json()) as Response;

ok ist hier der Discriminator, der zwischen den beiden Typen unterscheidet.

Auffällig ist:

  • Weder message noch data sind jetzt optional.
  • Man wird vom Typsystem gezwungen, auf ok zu prüfen, bevor man .data verwendet. Man kann es nicht vergessen.
  • Eine Funktion, die nur mit einer erfolgreichen Serverantwort etwas anfangen kann, kann dies in ihrer Parametersignatur sagen. Man spart sich das entpacken der Antwort sowie sonstige Checks innerhalb der Funktion.
  • Wenn Refactorings etwas daran ändern, merkt man das.
  • Es ist nicht möglich, ok: true und mesage: "test" zu haben - ungültige Zustände können hier nicht repräsentiert werden.

Eine Überprüfung, ob die Antwort jetzt erfolgreich war oder nicht, muss man natürlich so früh wie möglich machen, dann spart man sich das Überprüfen an späteren Stellen.

Das oben gezeigte Pattern lässt sich gut verwenden, um State-Machines typsicher zu implementieren.
Noch ein paar Pointer für andere Sprachen:

Das ist nur eine Methode, ungültige Zustände im Typsystem festzuhalten. Aber eine, die (meiner Meinung nach) zu wenig verwendet wird.