Effektive Fehlerbehandlung in TypeScript: Discriminated Unions statt geworfener Exceptions

#typescript fehlerbehandlung
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Gründer & Lead Developer

Experte für Softwareentwicklung und Legacy-Code-Optimierung

Ein Nutzer sendet ein Formular ab, die API liefert 500, und der Stack-Trace in Sentry zeigt auf eine Zeile drei Dateien entfernt von irgendetwas, das damit zusammenzuhängen scheint. Irgendwo zwischen dem Route-Handler und der Datenbank hat eine Funktion geworfen, und keine Typsignatur hat gewarnt, dass das passieren könnte.

Diese Lücke ist die praktische Schwäche der TypeScript-Fehlerbehandlung, wie sie die meisten Teams betreiben. TypeScript beschreibt den Wert, den eine Funktion zurückgibt. Es sagt nichts darüber, was die Funktion wirft, und es gibt keine throws-Klausel, die man hinzufügen könnte. Eine Funktion, die mit Promise<User> annotiert ist, gibt vielleicht einen Nutzer zurück, oder sie legt den Request lahm. Für den Compiler sind das dieselbe Signatur, und ein Aufrufer, der den zweiten Fall vergisst, kompiliert sauber.

Java hat darauf mit Checked Exceptions geantwortet, und viele, die das durchlebt haben, würden die Erfahrung lieber nicht wiederholen. Der Ansatz, zu dem wir bei TypeScript-Projekten immer wieder zurückkehren, ist älter und weniger umständlich: Fehler in den Rückgabetyp legen.

Warum TypeScript-Fehlerbehandlung durch das Typsystem sickert

Exceptions sind Kontrollfluss, der die Signatur umgeht. Das ist nützlich, wenn wirklich etwas schiefgelaufen ist, etwa eine verlorene Datenbankverbindung oder ein Bug, der einen unmöglichen Zustand erzeugt hat. Es passt schlecht zu Fehlern, die man bereits kennt und behandeln will: ein Datensatz, der nicht existiert, ein Nutzer ohne die richtige Rolle, eine Eingabe, die die Validierung nicht besteht.

Das sind keine Ausnahmefälle. Es sind gewöhnliche Ergebnisse der Operation, und so zu tun, als wären sie es nicht, kostet zwei Dinge. Der Compiler kann nicht sagen, dass eine Aufrufstelle sie ignoriert, und catch liefert unknown, sodass man am Ende instanceof-Ketten schreibt, die keine Exhaustiveness-Prüfung jemals verifiziert.

Ein Result-Typ in neun Zeilen

Alles Folgende baut auf einer Deklaration auf:

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

export const ok = <T>(value: T): Result<T, never> => ({ ok: true, value });
export const err = <E>(error: E): Result<never, E> => ({ ok: false, error });

ok ist der Diskriminant. Weil sein Typ das Literal true oder false ist statt boolean, grenzt TypeScript das gesamte Objekt ein, sobald man es testet:

const result = await findUser(id);

if (!result.ok) {
  // result.value existiert hier nicht, und der Compiler weiß das
  return renderError(result.error);
}

// result.value ist User, kein Cast, kein Optional Chaining
return renderProfile(result.value);

Diese Eingrenzung ist es, die die Arbeit macht. Um result.value zu erreichen, muss man zuerst den Fehlerzweig durchlaufen, und es gibt keinen anderen Weg dorthin. Kein Linter, keine Code-Review-Checkliste, keine Konvention, die unter Deadline-Druck vergessen wird. Das ist auch eines der Argumente dafür, Strict Mode in einer gesamten TypeScript-Codebasis zu aktivieren, da Eingrenzung deutlich weniger nützt, wenn strictNullChecks ausgeschaltet ist.

Getaggte Fehlerarten und erschöpfende Switches

Ein Fehlertyp von Error oder string wirft die Information weg, die das Ganze erst lohnenswert gemacht hat. Gib jeder Fehlerkategorie ihr eigenes Tag:

type ValidationError = { kind: 'validation'; field: string; message: string };
type NotFoundError = { kind: 'not_found'; resource: string; id: string };
type PermissionError = { kind: 'permission'; requiredRole: string };

type AppError = ValidationError | NotFoundError | PermissionError;

Jetzt ist die Abbildung von Domänenfehler auf HTTP-Antwort ein Switch, den der Compiler prüft:

function toStatus(error: AppError): number {
  switch (error.kind) {
    case 'validation':
      return 422;
    case 'not_found':
      return 404;
    case 'permission':
      return 403;
    default: {
      const unreachable: never = error;
      return unreachable;
    }
  }
}

Die never-Zuweisung im Default-Zweig ist der Teil, der sich auszahlt. Fügt man in sechs Monaten eine vierte Fehlerart hinzu, kompiliert diese Funktion nicht mehr, bis jemand entscheidet, welchen Statuscode sie verdient. Mit instanceof-Ketten in einem catch-Block geht dieselbe Änderung still durch und taucht in Produktion als 500 auf.

Jeder Fehler trägt strukturierte Felder statt einer formatierten Zeichenkette, sodass die API-Schicht field in einen Formularfehler serialisieren kann und die Log-Zeile auf resource indizieren kann, ohne Prosa zu parsen.

Weiterreichen ohne erneutes Werfen

Einen Fehler den Stack hinaufzureichen ist eine Return-Anweisung:

async function publishArticle(
  id: string,
  actor: Actor,
): Promise<Result<Article, AppError>> {
  const article = await findArticle(id);
  if (!article.ok) return article;

  const allowed = canPublish(actor, article.value);
  if (!allowed.ok) return allowed;

  return ok(await markPublished(article.value));
}

return article ist typkorrekt, weil ein Result<Article, NotFoundError> in seinem Fehlerzustand einem Result<Article, AppError> zuweisbar ist. Wenn ein Helper einen Fehlertyp zurückgibt, der nicht Teil deiner Union ist, mappe ihn an der Aufrufstelle, statt AppError aufzuweiten, bis er nichts mehr bedeutet.

Drei zusätzliche Zeilen pro Aufruf sind die ehrlichen Kosten. Im Gegenzug erzählt einem das Lesen der Funktion jeden Weg, auf dem sie fehlschlagen kann, der Reihe nach, ohne die Dateien zu öffnen, die sie aufruft.

Die Grenze, an der Result wieder zur Exception wird

Frameworks fangen Exceptions ab, keine Rückgabewerte. Next.js rendert error.tsx, wenn eine Server Component wirft, und Symfony führt seine Exception-Listener aus, wenn ein Controller wirft. Dagegen anzukämpfen lohnt sich nicht. Konvertiere an der Grenze:

export async function POST(request: Request) {
  const body = await request.json();
  const result = await publishArticle(body.id, await currentActor());

  if (result.ok) {
    return Response.json(result.value, { status: 200 });
  }

  return Response.json({ error: result.error }, { status: toStatus(result.error) });
}

Route-Handler geben den Fehler als Daten zurück, weil ein 404 eine legitime Antwort ist und kein Absturz. Server Components, die auf einen nicht behebbaren Fehler stoßen, sollten werfen, damit die nächste Error Boundary übernimmt. Die Regel, die wir anwenden: alles unterhalb der Framework-Grenze gibt Result zurück, und genau eine Schicht entscheidet, ob ein bestimmter Fehler zu einem Antwortkörper oder einer Exception wird.

Teams, die mit PHP und TypeScript arbeiten, werden die Form wiedererkennen. Ein Symfony-Exception-Listener macht dieselbe Übersetzung von Domänenfehler zu HTTP-Antwort, nur in die andere Richtung.

Zod macht das bereits

safeParse gibt eine Discriminated Union mit einem success-Flag zurück, was dasselbe Muster unter einem anderen Namen ist. Es anzupassen kostet ein paar Zeilen:

function parse<T>(schema: z.ZodType<T>, input: unknown): Result<T, ValidationError> {
  const parsed = schema.safeParse(input);
  if (parsed.success) return ok(parsed.data);

  const issue = parsed.error.issues[0];
  return err({
    kind: 'validation',
    field: issue.path.join('.'),
    message: issue.message,
  });
}

Nutze safeParse statt parse überall dort, wo die Eingabe von außerhalb deines Systems kommt. Ein Request-Body, der die Validierung nicht besteht, ist ein erwartetes Ereignis an jedem öffentlichen Endpunkt, und er sollte nicht als Exception reisen.

Eine try/catch-Codebasis migrieren, ohne neu zu schreiben

Jede Funktion auf einmal zu konvertieren ist der Weg, wie diese Initiative im Review stirbt. Fang stattdessen an den Nahtstellen an.

Umwickle Third-Party-Clients, die werfen, sodass das Werfen an deiner Grenze aufhört:

async function fetchInvoice(id: string): Promise<Result<Invoice, AppError>> {
  try {
    return ok(await billing.invoices.retrieve(id));
  } catch (cause) {
    if (isNotFound(cause)) {
      return err({ kind: 'not_found', resource: 'invoice', id });
    }
    throw cause;
  }
}

Dieses throw cause ist Absicht. Fehler, die man nicht vorhergesehen hat, sollten weiter zum Error-Tracker propagieren. Result ist für Ergebnisse, die man beschlossen hat zu behandeln, und es für alles zu nutzen, verwandelt echte Bugs in still verschluckte Rückgabewerte.

Von dort aus konvertiere ein Modul nach dem anderen, beginnend mit dem, das die meisten Incident-Tickets erzeugt. Bestehende Aufrufer behalten ihr try/catch, bis man sie erreicht. Die beiden Stile koexistieren ohne Probleme, weil eine Funktion, die Result zurückgibt, für erwartete Fehler nie wirft, und eine Funktion, die wirft, einfach noch nicht konvertiert ist.

Was es kostet

Die Ausführlichkeit ist real. Jeder Aufruf gewinnt eine Guard-Klausel, und tief verschachtelte Aufrufketten sammeln sie an. Bibliotheken wie neverthrow und fp-ts bieten map- und andThen-Kombinatoren, die das Verketten glätten, auf Kosten davon, dass jeder Reviewer funktionalen Stil fließend lesen können muss. Bei Teams mit gemischtem Erfahrungsniveau behalten wir meist die handgeschriebene Version, weil ein einfaches if kein Onboarding braucht.

Man verliert außerdem automatische Stack-Traces. Ein mit einem Literal gebautes Fehlerobjekt weiß nicht, woher es kam, also hängt man ein cause-Feld an oder erfasst Kontext bewusst, wenn ein Fehler eine Servicegrenze überquert.

Häufige Fragen

Sollte jede Funktion ein Result zurückgeben?

Nein. Pure Funktionen, die nicht fehlschlagen können, sollten ihren Wert zurückgeben. Reserviere Result für Operationen mit einem echten Fehlermodus, den man erwartet zu behandeln, etwa alles, was Netzwerk, Datenbank, Dateisystem oder unvalidierte Eingaben betrifft.

Ersetzt das mein Error-Monitoring?

Es ändert, was dort ankommt. Erwartete Fehler werden zu Daten und tauchen nicht mehr als Exceptions in Sentry auf, was das Rauschen meist deutlich reduziert. Unerwartete Fehler werfen weiterhin und werden weiterhin gemeldet, was genau das ist, worum es beim Alerting gehen soll.

Brauche ich neverthrow oder fp-ts?

Nicht zum Start. Der neunzeilige Typ gibt dir die Compiler-Garantie, das ist der ganze Sinn der Sache. Führe eine Bibliothek ein, wenn die manuellen Guards wirklich wehtun, und mach das zu einer Team-Entscheidung, statt dass ein Entwickler ein neues Paradigma in einem Pull Request einführt.

Wie funktioniert das mit TanStack Query?

Query behandelt ein abgelehntes Promise als Fehlerzustand, sodass ein von einer Query-Funktion zurückgegebenes Result als erfolgreiche Daten mit einem Fehler darin ankommt. Entweder entpackst du und wirfst innerhalb der Query-Funktion, oder du prüfst result.ok in der Komponente. Wähle eines und wende es überall an, denn das Mischen beider Varianten erzeugt Komponenten, die denselben Fehler an zwei Stellen behandeln.

Wo das reinpasst

Dieses Muster zahlt sich bei Codebasen mit echter Domänenlogik und mehreren Fehlermodi pro Operation aus, besonders wenn ein Team schon einmal von einem Produktionsvorfall überrascht wurde, den eine Typprüfung verhindert hätte. Bei einer kleinen CRUD-Anwendung ist es Overhead.

Wenn du eine Änderung wie diese gegen alles andere abwägst, das um den Sprint konkurriert, ist genau diese Abwägung die Frage, die unsere Code-Quality-Consulting-Arbeit beantworten soll, und Fehlerbehandlungsarchitektur ist etwas, das wir früh in Custom-Software-Development-Projekten festlegen, statt es später nachzurüsten.

Schick die Details an hello@wolf-tech.io, wenn du eine zweite Meinung zu einer Codebasis willst, oder lies mehr darüber, wie wir arbeiten, auf wolf-tech.io.