Ein Blog

Posts mit Tag "typescript"

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.

Einen Type-Checker schreiben: Reconstructing TypeScript, part 1

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.

TypeScript 4.5 (Beta).

Gute Änderung: TS unterstützt jetzt auch Node.js-Style-ES-Module. Das heißt, dass jetzt auch ordentlich mit ES-Imports umgegangen werden kann, wenn keine Dateiendung dabei ist. Das war bisher immer etwas doof, wenn man bestehenden Code als ES-Module emittiert, da man bei Node üblicherweise die Dateiendungen nicht mit angibt. Die Alternative wäre gewesen, von .js zu importieren, was aber auch doof war, weil man ja eigentlich von einer .ts-Datei importiert. Die Imports wurden bisher nicht zu der entsprechenden .js-Datei umgeschrieben.

Template-Literal-Strings kann man jetzt auch als Discriminator nehmen:

interface Success {
    type: `${string}Success`;
    body: string;
}
interface Error {
    type: `${string}Error`;
    message: string
}
function handler(r: Success | Error) {
    if (r.type === "HttpSuccess") {
        // 'r' has type 'Success'
        let token = r.body;
    }
}

Hätte eigentlich gedacht, dass das vorher auch schon gehen würde. Cool, dass es jetzt geht.

Außer, dass Import Assertions jetzt unterstützt werden, gibt es eigentlich sonst keine krassen Neuerungen. Eher viel kleines für besseren Interop mit anderem.

Bei TypeScript wurde neulich der Pull-Request Control Flow Analysis for Destructured Discriminated Unions gemergt. Das hört sich erstmal kompliziert an, das Prinzip ist aber ganz simpel: Wenn eine Variable aus einem Objekt kommt, und mit der Variable irgendwelche Überprüfungen stattfinden, hat das auch Auswirkungen auf den Typen des Objekts, aus dem die Variable ursprünglich mal kam.

Bisher konnte man den Kontrollfluss auch schon verwenden, um den Typen des Objekts genauer zu spezifizieren. Allerdings musste man dann immer direkt auf das Objekt referenzieren:

type Action =
    | { kind: 'A', payload: number }
    | { kind: 'B', payload: string };

function f11(action: Action) {
    if (action.kind === 'A') {
        // `action.kind` ist 'A', daher ist `action.payload` an dieser Stelle eine number
        action.payload.toFixed();
    }
    if (action.kind === 'B') {
        // `action.kind` ist 'B', daher ist `action.payload` an dieser Stelle ein string
        action.payload.toUpperCase();
    }
}

Das neue aus dem PR ist, dass das Destructuring die Verbindung zur Herkunft nicht verliert. Und das funktioniert jetzt sogar bi-direktional. Also funktioniert jetzt auch das:

type Action =
    | { kind: 'A', payload: number }
    | { kind: 'B', payload: string };

function f11(action: Action) {
    const { kind, payload } = action;
    if (kind === 'A') {
        // `kind` kam aus `action`, daher ist `payload` an dieser Stelle eine number
        payload.toFixed();
    }
    if (kind === 'B') {
        // `kind` kam aus `action`, daher ist `payload` an dieser Stelle ein string
        payload.toUpperCase();
    }
}

Ich will mir garnicht ausmalen, wie komplex die Umsetzung dafür gewesen sein muss.

Gerade ist dieses Peoposal in meiner Timeline aufgetaucht. Ein Author ist Daniel Rosenwasser, bekannt aus dem TypeScript-Projekt.

Idee: einen Großteil der TS-Syntax in JS aufnehmen, allerdings ohne Typüberprüfung. Die Type-Annotations sollen von der Engine komplett ignoriert werden, ist also an der Stelle ähnlich zu dem Type-Hints aus Python. Nur kann man bei Python noch zur Laufzeit auf die Typen zurückgreifen. Das würde hier auch entfallen.

Als MS mit TypeScript angefangen hat, hätte Anders geäußert, dass statische Typen in der JS-Welt nicht gern gesehen sind - zumindest nicht in der Kernsprache. In dem Proposal ist ein Screenshot aus The-State-Of-JavaScript. Dort wünschten sich die Teilnehmer der Umfrage - mit Abstand - statische Typisierung am meisten. Das sieht für mich aus, als hätte MS da eine ziemliche Erfolgsstory mit TypeScript hingelegt.

Das Proposal wurde nichtmal dem TC39 vorgelegt - es wird also noch sehr lange dauern, sollte etwas in dieser Richtung kommen. Falls es kommt, wäre das ein ziemlicher Game-Changer. Man erspart sich damit, .d.ts-Dateien mit auszuliefern. Da JS dann größtenteils quasi das ist, was TS heute ist, wird TS dadurch deutlich zurück gehen und vorrangig nur noch als Type-Checker für JS dienen. Es würde tatsächlich vieles vereinfachen.

Spannend!

TypeScript 4.7. Ein paar interessante Sachen sind dabei. Man kann jetzt Co- und Kontravarianz angeben. Dazu gibt’s hier ein Video, das das erklärt.

Der Meilenstein für TypeScript 5. Darunter:

Implement the Stage 3 Decorators Proposal

Das wird ziemlich schlimm. TS hat ja bereits Decorators, die von Angular- (und TypeORM-) Menschen schon viel benutzt werden. Ich war nie ein Fan davon. Dazu kommt, dass sich das Proposal, auf dem die Decorator baiseren, in der Zwischenzeit mehr als 2x verändert hat. Das wird noch interessant, wie das Ökosystem diese Migration schaffen wird.

Reduce typescript package size

TS ist mittlerweile >60 MiB groß. TS selbst benutzt intern keine ES-Module, sondern namespaces. Das ist ein überbleibsel aus den Anfängen von TS, als es noch viele Dinge aus C# übernommen hat (wie z. B. Enums). Jetzt gibt es einen PR, der das ändert. Das TS-Team ist damit das erste Mal damit konfrontiert, dass sie sehr viele Modul-Imports haben werden, da es die bei den Namespaces vorher nicht gab. Das wird die (aus meiner Sicht sehr einfache) Codestruktur des Compilers vielleicht langfristig ändern.

Aber das ist nicht, warum ich diesen PR verlinke. Sie konnten die Package-Größe von >60 MiB auf ~35 MiB senken. Wie? Neben ein bisschen Tree-Shaking und entfernen von identischen Dateien haben sie in einem Build-Prozess aus 4 Spaces einfach 2 Spaces gemacht. Hier hat jemand einen proof-of-concept gebracht und gezeigt, dass man noch mal 2 MiB runter bekommt, wenn man die 2 Spaces durch einen Tab ersetzt. Irrelevant für Komprimierung, aber ggf. relevant für einen Parser, der 2 MiB weniger lesen muss.

TypeScript Flag Deprecation Plan

Sie fangen an, alte Flags aus der Anfangszeit zu deprecaten und bis 2024 entfernen. Sowas gab es bisher noch nicht, daher müssen sie erstmal schauen, wie sie dort vorgehen. Die Kandidaten für die ersten Deprecations gefallen mir schon:

  • noFallthroughCasesInSwitch - style concern; use a linter if this is not allowed in your coding style
  • target: "ES3"
  • module - Remove umd, system and amd

Ein weiteres Ticket, das mir persönlich nicht gefehlt, aber vielen anderen: Allow voluntary .ts suffix for import paths. Der Hintergrund ist, dass TS keine Import-Pfade umschreibt. Was also in einem import * as a from "foo" steht, bleibt da so. Mit dem Aufkommen von ESM muss man u. A. für Browser- und Deno-Kompatibilität ein “.js” dort dran hängen. Dann befindet man sich in der Situation, in der man in einer .ts-Datei plötzlich eine andere .ts-Datei als .js-Datei importieren muss. Warum mich das nicht stört: Wenn man TS als reinen Type-Checker sieht, ergibt das Sinn, wenn TS ist nichts weiteres als JS mit Typen. Im Extremfall wird es ausschließlich als Type-Checker verwendet (neuere Frontend-Stacks tun genau das, seitdem Babel und ESBuild TS-Syntax unterstützen). Das wird durch das Type-Annotation-Proposal natürlich noch auf die Spitze getrieben. Letztendlich wäre dann die Idee: Ändere deine Dateiendung wieder zurück auf .js und du hast von TS überprüften Code. Wenn man aber in der Zwischenzeit überall seine Importe auf .ts geändert hat, um seine OCD zu befriedigen, hat man an der Stelle mehr arbeit.

TypeScript 5.0 Beta ist da. Zu ein paar Features hatte ich auch hier schonmal berichtet.

TypeScript 5 ist jetzt da. Hier sind auch schon die Pläne für TS 5.1. Die Sachen aus 5.1 lesen sich für mich spannender als die aus 5.0.

TypeScript 5.1 ist da. Auf der Build gab es einen Talk von Anders zu dem Release. Ist leider noch nicht auf YouTube hochgeladen. Vielleicht kommt es aber auch nie.

Kaum ist das using (und async using) in JS standardisiert, kommt TypeScript mit support dafür.

Die Nightly-Version von TS im Playground kann es scheinbar noch nicht. Bin gespannt, zu sehen, wie das vim Compiler in ältere JS-Versionen gelowert wird.

Dokumentation von TS-Projekten wird bisher immer relativ dezentral gemacht. In meinen Projekten benutze ich tsdoc, ein CLI-Tool zum erstellen einer statischen Seite, um eine Seite für GitHub Pages zu generieren. In anderen Sprachen gibt’s ne zentrale Stelle, die die Docs aus den Paketen in der entsprechenden Paketverwaltung ziehen (z. B. bei Rust). Das hat vor allem den Vorteil, dass die Docs immer zu der bestimmten Version passen. Das Versionieren müsste man vorher selbst übernehmen - und dann war auch nicht garantiert, dass die Versionen nicht divergiert sind.

Jetzt hat jemand tsdocs.dev gebaut. Die Seite lädt das npm-Paket und baut aus den Typen innerhalb des Pakets eine Seite, die ähnlich zu den normale tsdoc-Seiten aussieht, zusammen mit der Version.

Interessante Lösung. Mal schauen, ob sich das durchsetzt.

Für TS 5.4 steht im Iteration-Plan ein Champion zu Throw Expressions auf der Liste. Kommt von rbuckton, einem TS-Engineer bei MS. Das Feature ansich begrüße ich. C# hat das “Problem” auch so gelöst, womit man sehr viele Dinge abkürzen kann. Bei Java sind throws selbst mit den neuen Switch-Expressions weiterhin noch Statements, weshalb sie für Switch extra die Grammatik angepasst haben, um throw zu erlauben. Das würde for free kommen, wenn throw einfach eine Expression wäre.

Wenn ich das richtig sehe, steht das Proposal kurz vor Stage 3 und damit kurz vor der finalen Umsetzung in JS.

TypeScript 5.5 Beta.

TypeScript now infers a type predicate for the filter function.

// Previously, nums: (number | null)[]
// Now, nums: number[]
const nums = [1, 2, 3, null, 5].filter(x => x !== null);

Das war eine lange Baustelle und nun geht es endlich. Das Problem war lange Zeit, dass diese Lambdas keinen Type-Guard darstellen und somit die Information verloren geht, dass es bspw. nicht undefined ist. Das erkennt TS jetzt automatisch:

TypeScript will infer that a function returns a type predicate if these conditions hold:

  1. The function does not have an explicit return type or type predicate annotation.
  2. The function has a single return statement and no implicit returns.
  3. The function does not mutate its parameter.
  4. The function returns a boolean expression that’s tied to a refinement on the parameter.

But TypeScript now does basic syntax checking on regular expressions!

Praktisch!

Ich hab ja mal einen Prototypen eines SQL-Parsers auf Typebene entwickelt, um rohes SQL statisch zu typisieren. Hier eine Einführung, wie man auf Typebene in TypeScript programmieren kann.

Der Compiler von TypeScript ist ja ein nicht-optimierender Compiler. D.h. er lässt auch unerreichbaren Code im Resultat. Das ist durchaus so gewollt, heißt aber nicht, dass die Compiler-API sowas nicht hergeben würde. Hier hat das jemand genutzt und damit eine Dead-Code-Elimination gemacht: ts-remove-unused

Matt Pocock hat frohe Kunde verbreitet und angekündigt, dass in der nächsten Node-Version das “Strip Types” nicht mehr hinter einem Flag ist. Das heißt, dass Node.js bald nativ TypeScript ausführen kann. Die Types werden einfach rausgenommen und alles wie JS interpretiert. D.h. kein TS-Compiler-Check und keine Runtime-Checks. Ersteres ist quasi nicht möglich, ohne TSC in Node.js zu integrieren. Das ist etwas doof, weil TypeScript keine Spec hat, sondern nur eine Referenzimplementierung. Runtime-Checks haben dasselbe Problen und manche Typen sind in TS so komplex, dass allein das Type-Checken bspw. eines Funktionsparameters mehr Zeit in Anspruch nehmen kann, als die eigentliche Funktion. Insoweit gut, dass beides kein Teil hiervon ist.

Was bei ihm aber nicht steht und was durchaus wichtig ist:

Imports müssen auf .ts enden

Das geht gegen die bisherige Vorgehensweise von Node-Devs bei JS (die haben üblicherweise die Erweiterung weggelassen) und gegen die Empfehlung von TypeScript selbst (.js verwenden, auch, wenn es eine .ts-Datei ist). Die Empfehlung der TS-Leute ist aligned mit dem, was tatsächlich iim ESM-Standard steht. TypeScript hat bisher auch darauf verzichtet, Import-Pfade umzuschreiben.

Bun hat hierfür einen Resolution-Algorithmus, der bei einem Import von ./foo von ./foo.js über ./foo.ts und ./foo.mts etc alles absucht. Hier kann es zu unerwartetem Verhalten kommen, wenn eine ./foo.js und eine ./foo.ts existiert. Aber hier ist es immerhin möglich, seine Imports zu lassen, wie sie sind. tsx (ein Node-Wrapper, der ähnlich wie die jetzige Implementierung von Node-Type-Stripping funktioniert) kann das auch.

Path-Aliase

Node möchte hier jetzt .ts in Imports. TypeScript hat in den sauren Apfel gebissen und 2 Compiler-Flags dafür eingebaut: allowImportingTsExtensions und rewriteRelativeImportExtensions. Ersteres erlaubt .ts in imports. Letzteres macht nach dem Kompilieren ein .js aus .ts. Das ist praktisch, um nach dem Kompilieren noch validen JS-Code zu haben, denn sonst würde dort in den Imports js .ts stehen.

Was nicht umgeschrieben wird, sind nicht-relative imports. D. h. alle Imports, die nicht mit ./ oder ../ beginnen, bleiben so.

Manche Projekte haben einen "@/*"-Path-Alias, der auf das Root-Verzeichnis des Projekts zeigt. Falls ihr das habt, könnt ihr also nicht auf Node.js mit TS-Direktausführung migrieren. Dafür braucht man dann noch irgendeinen extra-loader wie bisher auch. Oder ihr benutzt Bun oder tsx. Damit das funktioniert, muss Node.js die tsconfig.json lesen und verarbeiten. Ich denke aktuell, dass das zu komplex wär, dass das Node-Team das umsetzt.

Bisher wird es auch dabei bleiben.

Also insgesamt leider noch ein ferner Traum, dass man TS für bestehende Projekte einfach mit Node.js direkt ausführen kann. Dafür braucht man immer noch einen modifizierten Loader, bun, Deno, ts-node oder tsx.

Microsoft portiert den TypeScript-Compiler in eine native Sprache. Das ist echt eine Mammutaufgabe. Sie benutzen Go.

Hier ist das Repo. Titel ist “TypeScript 7”. Da wir aktuell bei Version 5 sind, ist damit wohl klar, was sie für einen Zeitplan für die Fertigstellung haben.

Advent of TypeScript. Quasi Advent-of-Code, nur für TypeScript-spezifische probleme auf Typebene.