1 Punkte von GN⁺ 4 시간 전 | 1 Kommentare | Auf WhatsApp teilen
  • Wenn sich in TypeScript-Code Prüfungen wie if (user.email) verstreuen, bleibt die bereits geprüfte Tatsache nicht im Typ erhalten, sodass dieselbe Bedingung weiter hinten im Call Stack immer wieder angezweifelt wird
  • Ein Parser nimmt rohe Eingaben entgegen und gibt einen engeren Typ oder Fehlerinformationen zurück; so kann der Rest des Programms verifizierten Tatsachen wie EmailAddress vertrauen
  • In TypeScript mit seinem strukturellen Typsystem werden string und Email nicht natürlich getrennt, daher imitiert man nominale Grenzen mit Branded Types auf Basis von unique symbol und begrenzten as-Assertions
  • Eine Discriminated Union wie Parsed<T> macht Erfolg und Fehler in der Typsignatur sichtbar, aber ohne eigenen match-Ausdruck muss man Exhaustive Checks mit never selbst schreiben
  • Zod, io-ts und valibot können aus einem Schema zugleich Parser und TypeScript-Typen erzeugen, doch die Disziplin, an jeder Grenze zu parsen, bevor externe Eingaben als Domänentypen gelten, bleibt weiterhin Aufgabe der Entwickler

Validierung verwirft Informationen, Parsing bewahrt sie im Typ

  • Das Prinzip Parse, don’t validate von Alexis King stellt den Unterschied zwischen Validator und Parser in den Mittelpunkt
    • Ein Validator entscheidet „dieser Wert ist in Ordnung“ und gibt den Kontrollfluss dann per Boolean oder Exception weiter
    • Ein Parser nimmt rohe Eingaben entgegen und erzeugt einen präziseren Typ oder gibt den Grund des Scheiterns zurück
  • Wenn Typen breit bleiben, etwa User.email: string und User.age: number, kann TypeScript sich auch nach einem erfolgreichen isValidUser(user): boolean diese Tatsache nicht merken
  • In späterem Code wie emailService.send(user.email, ...) ist user.email weiterhin ein normaler string, also potenziell ein leerer String, "hello" oder "definitely not an email"
  • Ein Ablauf, der dieselben Bedingungen an mehreren Stellen erneut prüft, ähnelt dem von King so genannten shotgun parsing

APIs, bei denen der Typ selbst der Beweis ist

  • Die gewünschte Form ist eine Funktionssignatur, die nur geparste Werte akzeptiert, etwa sendWelcome(user: ValidUser)
  • In dieser Struktur muss vor dem Aufruf von sendWelcome zwingend ein Parser durchlaufen werden; innerhalb der Funktion sind keine erneute Validierung und keine defensiven ifs nötig
  • In Elm lässt sich das mit opaque types und Smart Constructors einfach lösen, in TypeScript braucht man für denselben Effekt mehr Hilfsmittel

Nominale Grenzen mit Branded Types schaffen

  • TypeScript verwendet ein strukturelles Typsystem, daher gelten Typen mit derselben Shape als derselbe Typ
    • string ist string, und es gibt keine Funktion, die wie Haskells newtype wirklich einen anderen Typ erzeugt
  • Der in der Community genutzte Workaround ist Branding oder Tagging
    • Eine einfache Variante ist ein Phantom-Feld mit String-Literal wie { readonly __brand: "Email" }
    • Eine stärkere Variante nutzt ein nicht aus dem Modul exportiertes unique symbol als Brand-Key
  • Beispieltypen haben die Form type Email = string & { readonly [EmailBrand]: true }, type Age = number & { readonly [AgeBrand]: true }
  • Das Brand-Feld ist ein Marker auf Typebene, der zur Laufzeit nicht existiert und dafür sorgt, dass Email und string zur Compile-Zeit unterschiedlich behandelt werden
  • Brands funktionieren nur in eine Richtung
    • Email kann string zugewiesen werden
    • Ein normaler string kann nicht direkt als Email hineingereicht werden

Parser erlauben Assertions nur an Vertrauensgrenzen

  • parseEmail(raw: string): Parsed<Email> gibt einen Fehler zurück, wenn der String kein @ enthält, und erzeugt bei Erfolg mit raw as Email den Branded Type
  • Die Assertion as Email ist eine erlaubte Ausnahme, weil der Parser die Vertrauensgrenze darstellt
    • Wenn anderswo in der Codebasis ein string als Email asserted wird, bricht das Design zusammen
    • Man kann den Parser in ein eigenes Modul legen und es als Bug behandeln, wenn Brand-Assertions außerhalb davon auftauchen
  • Das beispielhafte Parsed<T> hat die Form { kind: "ok"; value: T } | { kind: "err"; error: ParseError }
    • Fehler verstecken sich nicht in Exceptions, sondern erscheinen in der Typsignatur
    • Mit String-Discriminators wie kind: "ok" | "err" funktioniert Type Narrowing ehrlicher, wenn später Varianten hinzukommen
  • Das Beispiel parseEmail ist absichtlich dünn gehalten; ein echter E-Mail-Parser müsste zusätzlich trimmen, in Kleinbuchstaben normalisieren, Domains prüfen und mehr

Rohe Eingaben und vertrauenswürdige Domänentypen trennen

  • Trennt man UnvalidatedUser und ValidUser, lassen sich Werte aus dem Netzwerk oder externen Eingaben klar von in der Domäne vertrauenswürdigen Werten unterscheiden
    • UnvalidatedUser hält id, email und age als unknown
    • ValidUser verwendet Branded Types wie UserId, Email und Age
  • Wenn auch UserId gebrandet wird, verhindert das Fehler, bei denen etwa eine andere ID wie OrderId an eine Stelle übergeben wird, die UserId verlangt
  • parseUser(raw: unknown): Parsed<ValidUser> verengt rohe Eingaben Schritt für Schritt
    • Prüfen, ob die Eingabe ein Objekt ist
    • Prüfen, ob die Felder id, email und age vorhanden sind
    • Prüfen, ob email ein String ist
    • Jeweils parseUserId, parseEmail und parseAge aufrufen und bei Fehler sofort zurückgeben
    • Wenn alles erfolgreich ist, ValidUser zurückgeben
  • Dieser Ansatz ist wortreicher als in F# oder Elm, aber sendWelcome(user: ValidUser) wird dadurch tatsächlich sicher

Stellen, an denen TypeScript stört

  • Der erste Reibungspunkt ist die Assertion as Email innerhalb des Parsers
    • In einer Sprache mit echten nominalen Typen kann ein Smart Constructor ohne Lüge einen neuen Typ zurückgeben
    • TypeScripts Brands sind virtuelle Typmarker, daher muss der Parser per Assertion darüber hinweggehen
  • Der zweite Reibungspunkt sind Exhaustive Checks
    • TypeScripts Discriminated Unions sind in diesem Stil mächtig, aber es gibt keinen eigenen match-Ausdruck
    • Man muss Muster wie const _exhaustive: never = result im default eines switch selbst schreiben
    • Wenn zu Parsed eine dritte Variante hinzukommt, schlägt die never-Zuweisung fehl und der Compiler zeigt die Stelle an
  • satisfies kann als höflicherer Escape Hatch als ein Cast dienen
    • const x = { ... } satisfies Config prüft den Typ, ohne Literaltypen unnötig zu verbreitern
  • Da JSON.parse any zurückgibt, ist es sicherer, das Ergebnis sofort als unknown zu annotieren
    • In der Form const raw: unknown = JSON.parse(input) entgegennehmen und danach den Parser entscheiden lassen, ob es ein Domänentyp ist
    • JSON.parse ist kein Validator, sondern der Deserialisierungsschritt, der Bytes in JS-Werte verwandelt

Bibliotheken wie Zod reduzieren Wiederholung

  • Zod, io-ts und valibot bieten dasselbe Muster auf bequemere Weise als handgeschriebene Parser
  • Das Zod-Beispiel erzeugt aus einem Schema zugleich Parser und TypeScript-Typ
    • z.object({ id: z.number().int(), email: z.string().email().brand<"Email">(), age: z.number().int().min(0).max(150).brand<"Age">() })
    • Mit z.infer<typeof ValidUserSchema> erhält man den Typ
    • ValidUserSchema.safeParse(rawInput) gibt bei Erfolg data und bei Fehler error zurück
  • Auch Zods .brand() ist wie ein handgemachter Symbol-Brand eine Funktion auf Typebene und hat kein Laufzeitverhalten
  • Bibliotheken machen es leichter, Grenzen einzuhalten, indem sie Parser und Typ in derselben Definition bündeln; sie erzwingen aber nicht die Disziplin, sie an jeder externen Grenze zu verwenden
  • Ein User aus dem Netzwerk ist bis zum Parsen kein Domänen-User, und man sollte der Versuchung widerstehen, Fehlermeldungen mit Typ-Assertions zu umgehen

Beweise nicht im Gedächtnis, sondern im Typ speichern

  • Das kleine Prinzip lautet: „Lass das Typsystem die Beweise tragen und vertraue nicht auf menschliches Gedächtnis“
  • Wenn eine Bedingung geprüft wird, das Ergebnis aber nicht im Typ codiert ist, nimmt späterer Code leicht an, diese Validierung sei bereits erledigt
  • In TypeScript wird dieses Prinzip mit drei Werkzeugen umgesetzt
    • Branded Types, die nominale Identität imitieren
    • Discriminated Unions, die Erfolg und Fehler sichtbar machen
    • eine strikte Grenze zwischen unknown aus externen Eingaben und vertrauenswürdigen Domänentypen
  • Nicht jeder Code sollte immer in eine Parsing-Pipeline verwandelt werden; wenn sich jedoch dieselben defensiven ifs über mehrere Dateien wiederholen, ist das ein Signal, dass wichtige Validierungsinformationen nicht im Typ gelandet sind

1 Kommentare

 
GN⁺ 4 시간 전
Kommentare auf Lobste.rs
  • Wenn JavaScript/TypeScript technisch und ergonomisch mit dem gewünschten Codestil kollidiert, könnte man doch einfach eine der vielen Sprachen verwenden, die nach JS kompilieren.
    Haskell, Elm und F# werden erwähnt, und es gibt viele weitere Sprachen aus der Richtung, die der Autor offenbar lieber nutzen würde, etwa PureScript, js_of_ocaml, Reason oder LunarML. Der Autor hat sogar einen Beitrag namens Why TypeScript Won’t Save You geschrieben und dort noch stärker mit seinen bevorzugten Sprachen verglichen; außerdem betreibt er https://learnelm.dev.
    Oder vielleicht ist der Vergleich selbst der Zweck: zu zeigen, dass TypeScript in vielen Fällen nicht ausreicht, und dazu anzuregen, andere Toolchains oder Ideen zu übernehmen.

    • Es gibt Einschränkungen wie bestehende Codebases, bestimmte Sprachkenntnisse im Team oder Unternehmensvorgaben sowie weniger Support, Tools und kleinere Communities.
      Die meisten haben schlicht nicht die Wahl oder die Zeit, eine andere Sprache zu wählen.
    • Vermutlich liegt es meist daran, dass es bereits eine große TypeScript-Codebase gibt oder dass man TypeScript-Bibliotheken nutzt, die es in anderen Sprachen nicht gibt.
  • Bei der Arbeit mag ich Branded Types sehr, aber es nervt mich wirklich, dass man kein Array oder TypedArray erstellen kann, das nur mit branded Numbers indiziert werden kann.
    TypedArray kann branded Numbers nicht speichern — genauer gesagt nicht einmal korrekt wieder auslesen. Selbst wenn dafür ein eigener Satz von Typen wie IndexArray oder IndexTypedArray nötig wäre, würde ich mir diese Funktion unbedingt wünschen.

    • Ich mag Branded Types auch, aber wenn man mit anderen darüber spricht, finden viele, dass sie den Aufwand nicht wert sind.
      Wenn man in einem ziemlich komplexen Datenbankschema für alle IDs Branded Types verwendet, fängt TypeScript unsinnige Joins oder Bedingungen ab. Auch Funktionssignaturen werden klarer, und es wird schwieriger, diverse Fehler zu machen.
    • Wenn man bereit ist, stark genug zu lügen, kann man ein Array erstellen, das nur mit branded Numbers indiziert werden kann.
      Wenn man möchte, geht das auf dieselbe Weise auch für Werte in TypedArrays.
    • Bei der Arbeit verwenden wir „smarte enums“ und benutzerdefinierte Array-Typen, sodass man etwas wie TArray<Foo, MyEnum> schreiben kann. Allerdings geht es hier um C++.
      In Zigs std-Bibliothek gibt es ein per comptime implementiertes EnumArray. Es bietet auch weitergehende Funktionen, etwa dichte oder dünn besetzte enums als Indizes zu verwenden und zur Compile-Zeit den korrekten Indexer zu berechnen.
      Diese Art von präziser Typisierung gefällt mir immer besser. Sie verhindert in großem Umfang, dass logische Bugs überhaupt in die Codebase gelangen.