In jedem API-Client steckt eine state machine. Zuerst der Handshake. Zweitens die Authentifizierung. Drittens die Datenübertragung. Zum Schluss das Schließen. Wenn du diese Reihenfolge durchbrichst, gibt es Runtime-Fehler, verwirrte Server oder schlimmer: stille Datenkorruption.
Die meisten Teams kodieren diese Regeln mit Runtime-Checks. if (!connected) throw new Error(...). Das funktioniert, bis jemand den Check vergisst oder ein Refactor einen neuen Codepfad einführt, der ihn überspringt. Wenn du es bemerkst, ist der Bug bereits in der Produktion.
TypeScripts Typsystem kann eine ganze Klasse dieser Bugs eliminieren. Nicht mit cleveren Lint-Regeln, sondern indem es illegale Zustände buchstäblich nicht darstellbar macht.
Dein API-Client hat eine versteckte state machine
Session Types sind eine Typsystem-Technik, um die gültige Sequenz von Operationen in einem protocol zu kodieren. Statt den Zustand zur Laufzeit mit einem String-Feld zu verfolgen, verfolgst du ihn im Type-Parameter. Ein Channel<'idle'> kann nur connect() aufrufen. Ein Channel<'connected'> kann nur send() und close() aufrufen. Der Compiler lehnt alles andere ab.
Das ist nicht akademisch. Wenn du schon einmal eine Datenbanktransaktion, eine WebSocket-Verbindung oder einen OAuth-Flow verwendet hast, hast du manuell ein session protocol durchgesetzt. Session Types verschieben diese Durchsetzung einfach von der Laufzeit in die Kompilierzeit.
Phantom Types machen illegale Übergänge nicht darstellbar
Hier ist ein typische state machine in TypeScript:
class Channel {
private state: 'idle' | 'connected' | 'closed' = 'idle';
connect() {
if (this.state !== 'idle') throw new Error('Already connected');
this.state = 'connected';
}
send(msg: string) {
if (this.state !== 'connected') throw new Error('Not connected');
// ...
}
close() {
this.state = 'closed';
}
}
Das ist in Ordnung, bis es das nicht mehr ist. Die send()-Methode wirft zur Laufzeit, wenn du sie zum falschen Zeitpunkt aufrufst. Tests könnten das erkennen. Müssen sie aber nicht. Ein Refactor könnte einen neuen send()-Aufruf nach close() einführen, den niemand bemerkt. Der Compiler hat keine Meinung dazu.
TypeScript verwendet Structural Typing. Zwei Klassen mit demselben Shape sind austauschbar, auch wenn sich ihre Type-Parameter unterscheiden. Um Channel<'idle'> und Channel<'connected'> inkompatibel zu machen, muss der Type-Parameter in der Struktur selbst auftauchen.
Das Standard-Pattern ist ein privates Phantom-Feld:
class Channel<State extends string> {
private __state!: State;
private constructor() {}
static create(): Channel<'idle'> {
return new Channel();
}
connect(this: Channel<'idle'>): Channel<'connected'> {
return new Channel();
}
send(this: Channel<'connected'>, msg: string): Channel<'connected'> {
console.log(msg);
return new Channel();
}
close(this: Channel<'connected'>): Channel<'closed'> {
return new Channel();
}
}
Das __state-Feld bekommt nie einen Wert. Es existiert nur, um den Type-Parameter an die Klassenstruktur zu binden. Weil es privat ist, kann externer Code kein Channel<'connected'> fabrizieren. Weil es den Typ State hat, sind Channel<'idle'> und Channel<'connected'> strukturell verschiedene Types.
Der this-Parameter auf jeder Methode schränkt ein, welche Zustände sie aufrufen dürfen. TypeScript prüft die this-Kompatibilität an der Call Site, nicht nur im Methodenkörper.
Jetzt sind illegale Aufrufe Compile-Time-Fehler:
const ch = Channel.create();
ch.send('hello'); // Error: 'Channel<"idle">' is not assignable to 'Channel<"connected">'
Das ist der Teil, der Leute durcheinanderbringt. Der this-Parameter sieht aus wie eine Runtime-Annotation. Ist er nicht. TypeScript verwendet ihn rein für die Typprüfung des Receivers. Wenn der Receiver-Typ nicht passt, lehnt der Compiler den Aufruf komplett ab.
Ein typsicheres file upload protocol bauen
Das Spielzeug-Channel zeigt das Pattern. Hier ist etwas, das näher an einem echten protocol ist: ein Datei-Upload-Client mit Authentifizierung und Prüfsummenverifizierung.
type UploadState = 'idle' | 'ready' | 'uploading' | 'verifying' | 'done';
class Uploader<State extends UploadState> {
private __state!: State;
private file: string;
private constructor(file: string) {
this.file = file;
}
static forFile(file: string): Uploader<'idle'> {
return new Uploader(file);
}
authenticate(
this: Uploader<'idle'>,
token: string
): Uploader<'ready'> {
// verify token...
return new Uploader(this.file);
}
uploadChunk(
this: Uploader<'ready' | 'uploading'>,
data: Uint8Array
): Uploader<'uploading'> {
// stream bytes...
return new Uploader(this.file);
}
finalize(
this: Uploader<'uploading'>,
checksum: string
): Uploader<'verifying'> {
// start verification...
return new Uploader(this.file);
}
verify(
this: Uploader<'verifying'>,
serverChecksum: string
): Uploader<'done'> {
// compare checksums...
return new Uploader(this.file);
}
}
Beachte, dass uploadChunk Uploader<'ready' | 'uploading'> als this-Typ akzeptiert. Union-Types erlauben es dir, Methoden auszudrücken, die aus mehreren Zuständen gültig sind. Die Methode gibt Uploader<'uploading'> zurück, sodass du nach dem ersten Aufruf im Upload-Zustand steckst, bis du finalisierst.
const chunk1 = new Uint8Array([0x01, 0x02]);
const chunk2 = new Uint8Array([0x03, 0x04]);
const upload = Uploader.forFile('report.pdf')
.authenticate('token-123')
.uploadChunk(chunk1)
.uploadChunk(chunk2)
.finalize('abc123')
.verify('abc123');
// upload is now Uploader<'done'>
Versuche, authenticate() nach uploadChunk() oder uploadChunk() nach verify() aufzurufen, und TypeScript meldet sofort einen Fehler.
Wo das Pattern unhandlich wird
Das Pattern ist nicht umsonst. Der größte Schmerzpunkt ist, dass jeder Zustandsübergang ein neues Objekt konstruiert. In unseren Beispielen erstellen wir einen neuen Uploader, selbst wenn der zugrunde liegende Zustand sich nicht geändert hat, wie bei uploadChunk, das Uploader<'uploading'> aus Uploader<'uploading'> zurückgibt. Für komplexe Objekte mit viel Zustand ist das verschwenderisch.
Du kannst optimieren, indem du den Zustand durchreichst statt neu zu konstruieren, aber die Types werden lauter. Ein zweites Problem: TypeScripts Fehlermeldungen für this-Parameter-Fehler sind kryptisch. „The ‘this’ context of type ‘X’ is not assignable to method’s ‘this’ of type ‘Y’“ ist korrekt, aber nicht sofort offensichtlich für jemanden, der den Code zum ersten Mal liest. Gute Namensgebung und Kommentare helfen, aber die Developer Experience ist nicht perfekt.
Drittens komponiert sich dieses Pattern nicht gut mit Async-Code. Ein await in der Mitte einer Chain bricht das Fluent Interface, und du musst das Zwischenergebnis mit Typ in einer Variable speichern. Das ist kein Dealbreaker, aber es bedeutet, dass das Pattern am besten für synchrone oder sorgfältig strukturierte async protocols funktioniert.
Tagged Unions sind der pragmatische Mittelweg
Wenn das volle Phantom-Type-Pattern zu schwer ist, sind Tagged Unions mit exhaustiven Switch-Statements eine pragmatische Alternative. Du bekommst immer noch Compile-Time-Sicherheit, aber auf der Wertebene statt auf der Type-Ebene:
type UploadState =
| { tag: 'idle'; file: string }
| { tag: 'ready'; file: string; token: string }
| { tag: 'uploading'; file: string; sent: number }
| { tag: 'done'; file: string };
function authenticate(
state: Extract<UploadState, { tag: 'idle' }>,
token: string
): UploadState {
return { tag: 'ready', file: state.file, token };
}
Das ist idiomatischeres TypeScript und für die meisten Teams leichter zu pflegen. Der Kompromiss ist, dass du jemanden nicht daran hindern kannst, die falsche State-Variante an eine Funktion zu übergeben, ohne die gleichen this-Parameter-Tricks. Der Compiler erkennt es innerhalb der Funktion, aber die Call Site ist nicht eingeschränkt.
Greife zu Phantom Types, wenn Verletzungen echten Schaden anrichten
Verwende Phantom Types, wenn das protocol komplex genug ist, dass seine Verletzung echten Schaden anrichtet, und wenn die API-Oberfläche klein genug ist, dass du jeden Übergang kontrollierst. Datenbanktreiber, network protocol implementations und stateful SDKs sind gute Kandidaten.
Verwende sie nicht für einfache CRUD-APIs oder alles, wo der zusätzliche Type-Komplexität mehr kostet als der gelegentliche Runtime-Check.
Das nächste Mal, wenn du if (state !== 'connected') throw new Error(...) schreibst, frag dich, ob dieser Check in deinen Code oder in deine Types gehört. TypeScripts this-Parameter und Phantom-Felder geben dir einen Weg, die protocol enforcement nach oben zu verschieben. Die Bugs, die du erwischst, werden nicht die dramatischen sein. Es werden die leisen Fehler sein, die durch Code Review schlüpfen und erst auftauchen, wenn ein Kunde einen Edge Case trifft, den du nicht getestet hast.
Genau die Bugs lohnen es, eliminiert zu werden.