Deine Funktionssignatur sagt, sie gibt einen User zurück. Tut sie nicht. Sie gibt einen User zurück, oder sie explodiert. Das Typsystem weiß einfach nichts von dem zweiten Zweig.

Das ist die fundamentale Unehrlichkeit der exception-basierten Fehlerbehandlung. Jedes throw ist ein Kontrollflusspfad, den dein Compiler nicht sehen, nicht prüfen und nicht erzwingen kann. Am Ende hast du Code, der perfekt durch den Type-Checker geht und trotzdem in der Produktion abstürzt, weil jemand drei Aufrufebenen höher ein try/catch vergessen hat.

Explizite Error-Returns beheben das. Sie machen Failure zu einem First-Class-Citizen deines Typsystems. Die Frage ist, ob der Ergonomie-Aufwand die Ehrlichkeit wert ist.

Was Type-Level Correctness für Errors bedeutet

Type-Level Correctness bedeutet, dass deine Types die Wahrheit darüber sagen, was eine Funktion produzieren kann. Wenn eine Funktion fehlschlagen kann, sollte der Return-Type das sagen. Wenn nicht, sollte der Return-Type Erfolg garantieren.

Rust drückt das mit Result<T, E> aus:

fn parse_port(s: &str) -> Result<u16, ParseIntError> {
    s.parse()
}

Die Signatur ist vollständig. Aufrufer wissen, dass es zwei Ergebnisse gibt. Der Compiler zwingt sie, beide zu behandeln.

TypeScript tut das standardmäßig nicht. Eine Funktion, die wirft, sieht identisch aus wie eine, die nicht wirft:

function parsePort(s: string): number {
  const n = parseInt(s, 10);
  if (isNaN(n) || n < 0 || n > 65535) {
    throw new Error(`Invalid port: ${s}`);
  }
  return n;
}

Der Return-Type sagt number. Er sollte number | Error sagen, oder genauer, Result<number, Error>. Aber das tut er nicht, also haben Aufrufer kein Signal, dass sie sich gegen Failure wappnen müssen.

Wie geworfene Errors sich im Call Graph verstecken

Der Schaden summiert sich, wenn dein Call Stack tiefer wird. Ein geworfener Error in einer Leaf Function zwingt jede Zwischenschicht, ihn entweder zu fangen oder implizit weiterzugeben. Keiner dieser Propagationspfade erscheint in einer Type-Signatur.

Stell dir einen Config-Loader vor, der einen Parser aufruft, der einen Validator aufruft. Wenn der Validator wirft, werden Parser und Loader ebenfalls throwable. Aber ihre Types ändern sich nicht. Du kannst nicht auf loadConfig() schauen und wissen, dass es fehlschlagen könnte. Du musst jede Zeile jeder Dependency lesen oder auf einen Runtime-Crash warten.

Das ist genau das Gegenteil davon, wie Types arbeiten sollen. Types existieren, damit du nicht die Implementierung lesen musst, um den Contract zu verstehen.

Explizite Error-Returns kehren das um. Der Failure-Pfad ist auf jeder Ebene sichtbar:

type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };

function parsePort(s: string): Result<number, string> {
  const n = parseInt(s, 10);
  if (isNaN(n) || n < 0 || n > 65535) {
    return { ok: false, error: `Invalid port: ${s}` };
  }
  return { ok: true, value: n };
}

function loadConfig(raw: Record<string, string>): Result<Config, string> {
  const portResult = parsePort(raw.port);
  if (!portResult.ok) return portResult;

  const host = raw.host || "localhost";

  return { ok: true, value: { port: portResult.value, host } };
}

Jetzt erzählt loadConfigs Signatur die ganze Geschichte. Du weißt, dass sie fehlschlagen kann, bevor du auch nur eine einzige Zeile des Bodys gelesen hast.

Der wahre Trade-Off ist Ergonomie, nicht Expressiveness

Der ehrliche Ansatz ist ausführlicher. Niemand schreibt gerne { ok: false, error: ... } statt throw new Error(...). Das visuelle Rauschen summiert sich, besonders wenn du mehrere fehleranfällige Operationen chainst.

Deshalb haben die meisten Sprachen mit Checked Exceptions sie aufgegeben. Javas Checked Exceptions haben versucht, explizites Handling auf Type-Level zu erzwingen, aber die Syntax war punitiv. Rusts ?-Operator und Result-Combinators wie map und and_then holen den Großteil dieser Ergonomie zurück. TypeScript hat keinen ?-Operator, aber du kommst mit Helper-Funktionen nah ran:

function map<T, U, E>(result: Result<T, E>, fn: (value: T) => U): Result<U, E> {
  return result.ok ? { ok: true, value: fn(result.value) } : result;
}

function flatMap<T, U, E>(
  result: Result<T, E>,
  fn: (value: T) => Result<U, E>
): Result<U, E> {
  return result.ok ? fn(result.value) : result;
}

Es ist immer noch klobiger als Rust. Wenn du TypeScript schreibst, bieten Libraries wie neverthrow einen Result-Type mit chainable Methods, die sich fast nativ anfühlen. Der Overhead ist real, aber er ist kleiner, als er aussieht, sobald du Helpers hast.

Es gibt noch einen weiteren Kostenpunkt. Stack Traces von geworfenen Errors sind umfangreich und automatisch. Returned Errors sind einfach Values. Wenn du einen Stack Trace willst, musst du ihn selbst konstruieren. Fürs Debugging ist das wichtig. Für vorhersehbare Fehlerbehandlung in Business Logic meist nicht.

Wann Werfen immer noch Sinn macht

Wir plädieren nicht für null Exceptions. Manche Failures sind wirklich exceptional und sollten nicht Teil deines normalen Error Models sein. Eine fehlende Datei beim Config-Loading ist ein erwarteter Failure. Ein Out-of-Memory-Error ist das nicht.

Die Faustregel: Wenn ein Failure Teil deiner Domain ist, returne ihn. Wenn es ein Programming Bug oder ein katastrophaler System-Failure ist, wirf ihn. Dass JSON.parse bei malformed Input wirft, ist nervig, weil invalides JSON häufig ist. Dass Array.prototype.map bei einem null-Array wirft, ist in Ordnung, weil .map auf null aufzurufen ein Bug ist.

In der Praxis bedeutet das: Deine I/O-Boundaries, Parser, Validatoren und Business Rules sollten Errors returnen. Deine Invarianten, Assertions und wirklich katastrophalen Conditions können weiterhin werfen.

Fang bei deinen API-Boundaries an

Du musst nicht deine gesamte Codebase refactoren. Die wertvollsten Stellen, um explizite Error-Returns zu übernehmen, sind deine Public APIs und deine I/O-Layer. Das sind die Boundaries, an denen Aufrufer wissen müssen, was schiefgehen kann.

Nimm ein Modul. Ändere seine Return Types zu Result<T, E>. Aktualisiere seine Aufrufer. Schau, wie es sich anfühlt. Das Pattern ist auf eine spezifische Weise süchtig machend: Sobald du aufhörst, in Stack Traces nach fehlenden try/catch-Blöcken zu suchen, merkst du, wie viel mentaler Overhead geworfene Exceptions dich gekostet haben.

Type-Level Correctness für Errors geht nicht darum, perfekten Code zu schreiben. Es geht darum, Code zu schreiben, der weniger lügt.