Ein Blog

Posts mit Tag "software-engineering"

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.

Neulich hatte ich den bekannten “Parse, don’t validate”-Post verlinkt.

Als Follow-Up-Empfehlung gibt es hier zwei Posts:

Letzteres dreht sich vor allem um Haskells newtype. Vereinfacht gesagt ist das ein strikter Typalias. Also als TypeScript-Äquivalent:

type Grade = number;

Mit einem wichtigen Unterschied: Jede number ist automatisch eine valide Grade. Das liegt daran, dass das Typsystem von TS (an den meisten Stellen) strukturell und nicht nominell ist.

Diese newtypes sind eine strikte Variante davon, nämlich, dass man die Typen entweder explizit casten muss oder sie durch die Typinferenz gesichert werden. In TS gibt es newtype-Konstruktionen, Librarys und etliche Blogposts dazu. Manche nennen es auch “Branded Types”. Natürlich gibt es auch ein seit 2014 offenes Proposal dazu.

Die Aussage des Blogposts oben: Das kann Exhaustiveness-Checking kaputt machen. Wir stellen uns diese Funktion vor:

type Grade = Branded<number, "Grade">; // "newtype" für Grade

function isGrade(value: number): value is Grade {
    return 1 <= value && value <= 6;
}

function getGradeDescription(value: Grade): string {
    switch (value) {
        case 1: return "sehr gut";
        case 2: return "gut";
        case 3: return "befriedigend";
        case 4: return "ausreichend";
        case 5: return "mangelhaft";
        case 6: return "ungenügend";
        default: throw new Error("Impossible");
    }
}

let a = 4;
// a ist "number"
if (isGrade(a)) {
    // a ist "Grade"
    console.log(getGradeDescription(a));
}

Das ist doch schon ganz gut. Aber was ist jetzt das Problem?

Das Problem wird klar, wenn wir ein Refactoring machen und von dem 6-Noten-System auf z. B. ein 15-Punkte-System migrieren:

type Grade = Branded<number, "Grade">; // "newtype" für Grade

function isGrade(value: number): value is Grade {
    return 0 <= value && value <= 15;
}

// ...

let a = 4;
// a ist "number"
if (isGrade(a)) {
    // a ist "Grade"
    console.log(getGradeDescription(a));
}

Jetzt könnte es passieren, dass der Entwickler nicht mitbekommt, dass getGradeDescription auch angepasst werden muss — es gibt ja auch keinen Compilerfehler. Stattdessen erhalten wir einen Runtime-Fehler. Dabei dachten wir eigentlich, wir seien auf der sicheren Seite, denn wir haben immer den Grade-Type zugesichert.

Oben schrieb ich, dass Exhaustiveness-Checking kaputt gemacht würde. Rollen wir unseren Code also nochmal zurück vor das Refactoring und schauen, was wir hätten besser machen können. Exhaustiveness-Checking ist, wenn der Compiler prüfen kann, ob alle Fälle abgetestet wurden. Wenn wir den default-Case beim getGradeDescription weglassen:

function getGradeDescription(value: Grade): string {
    switch (value) {
        case 1: return "sehr gut";
        case 2: return "gut";
        case 3: return "befriedigend";
        case 4: return "ausreichend";
        case 5: return "mangelhaft";
        case 6: return "ungenügend";
    }
}

…erhalten wir einen Fehler:

Function lacks ending return statement and return type does not include ‘undefined’.

Der kommt daher, dass wir nicht alle Fälle abgedeckt haben und die Funktion nicht immer einen Wert zurückgibt, denn Grade ist ja letztenendes für den Compiler nur eine number, welche alle möglichen Werte annehmen kann. Wir wissen jedoch, dass dies nicht so ist! Der Type-Checker weiß das jedoch nicht. Wie können wir den Type-Checker zu unseren Gunsten verwenden?

Eine Antwort: Mit Literaltypen und Union-Types. Wir können Grade stattdessen so definieren:

type Grade = 1 | 2 | 3 | 4 | 5 | 6;

(Achtung: Das ist immernoch kein newtype, nur ein Typalias für dieses Union)

Dieser Typalias reicht schon aus, um den Compiler bei dem switch mit dem fehlenden default-Case zu befriedigen. Wenn wir jetzt unser Refactoring erneut durchführen, müssen wir Grade abändern:

type Grade = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15;

Jetzt bekommt wir sofort einen Fehler in der getGradeDescription-Funktion: Das Exhaustiveness-Checking sagt uns, dass wir nicht alle Fälle des Grade-Typen abgedeckt haben und der Entwickler weiß sofort, dass er diese Funktion anpassen muss, da sie nicht vergessen werden kann.

Zwei letzte Anmerkungen dazu:

  • Den default-Case würde ich in der TypeScript-Welt trotzdem nicht weglassen. Es ist immer gut, sich ein assertNever zu definieren und es im default-Case für den Wert, auf dem geswitched wird, zu verwenden. Dadurch wird der Fehler eindeutiger und sollte es zur Laufzeit doch irgendwie dazu kommen, kann im Fehelrfall zumindest eine Exception an der richtigen Stelle geworfen werden (TypeScript macht keine Runtime-Checks; eine inkorrekte Assertion würde dafür reichen).
  • Der Typ ist jetzt schon ziemlich lang — ein Union mit 16 Einträgen. Natürlich sollte man auf Fallbasis abwägen, ob es sich lohnt.
  • newtypes / branded Types sind trotzdem cool und können generell helfen, typsicherer zu sein. Es kann sich aber lohnen, seine Typen noch genauer zu spezifizieren.

Vor ein paar Tagen habe ich Hurl kennengelernt. Das ist ein CLI-HTTP-Client, wie cURL. Der Unterschied ist: Hurl holt sich die Request-Parameter aus einer Datei.

Warum ist das so viel anders als cURL? Man kann mehrere HTTP-Anfragen in einer Datei abbilden. Diese Anfragen können Daten aus den Ergebnissen der vorherigen Anfrage verwenden. Aus dem README:

# Get home:
GET https://example.net

HTTP/1.1 200
[Captures]
csrf_token: xpath "string(//meta[@name='_csrf_token']/@content)"

# Do login!
POST https://example.net/login?user=toto&password=1234
X-CSRF-TOKEN: {% raw %}{{csrf_token}}{% endraw %}

HTTP/1.1 302

Wie Ihr seht, kann man damit auch XPath-Querys auf die Antworten absetzen. Natürlich geht auch JSONPath. Das kann man mit einem [Asserts] kombinieren und sich somit HTTP-Tests bauen:

POST https://api.example.net/tests
{
    "id": "456",
    "evaluate": true
}

HTTP/1.1 200
[Asserts]
jsonpath "$.status" == "RUNNING"      # Check the status code
jsonpath "$.tests" count == 25        # Check the number of items

Intern verwendet das Tool natürlich cURL; man muss das Rad ja auch nicht neu erfinden.

Eine tolle Idee, wie man Farben beim Syntax-Highlighting anders verwenden kann: Syntax highlighting is a waste of an information channel