Ein Blog

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.