Zum Inhalt springen

Fehlerbehandlung in TypeScript: Muster, die skalieren

Weg vom try-catch-Wirrwarr: discriminated unions, Result-Typen und Fehlergrenzen, die TypeScript-Codebasen zuverlässiger und wartbarer machen.

3 Min. Lesezeit
TypeScript-Code, der strukturierte Fehlerbehandlung mit Result-Typen und discriminated unions zeigt

Die meisten TypeScript-Codebasen behandeln Fehler genauso wie JavaScript es schon immer getan hat: try/catch-Blöcke überall, unknown-gefangene Fehler zu any gecastet, und die Hoffnung, dass nichts Wichtiges durchrutscht. Das Typsystem, das dich schützen sollte, ist für Failure-Pfade völlig blind. Es gibt einen besseren Weg, und der erfordert keine Bibliothek.

Warum Try-Catch nicht skaliert

Das fundamentale Problem von try/catch als primäre Fehlerbehandlungsstrategie ist, dass Fehler auf Typebene unsichtbar werden. Die Signatur einer Funktion verspricht, User zurückzugeben, aber sie kann fünf verschiedene Exceptions werfen. Der Aufrufer hat keine Möglichkeit, das zu wissen, ohne die Implementierung zu lesen.

tstypescript
// ❌ The signature lies — this can fail in at least three ways
async function getUser(id: string): Promise<User> {
  const row = await db.query("SELECT * FROM users WHERE id = $1", [id]);
  if (!row) throw new Error("User not found");
  return parseUser(row); // can also throw if schema changed
}
 
// ✅ The type signature tells the whole story
async function getUser(
  id: string,
): Promise<Result<User, "NOT_FOUND" | "PARSE_ERROR" | "DB_ERROR">> {
  try {
    const row = await db.query("SELECT * FROM users WHERE id = $1", [id]);
    if (!row) return err("NOT_FOUND");
    const parsed = parseUser(row);
    if (!parsed.ok) return err("PARSE_ERROR");
    return ok(parsed.value);
  } catch {
    return err("DB_ERROR");
  }
}

Die zweite Version schließt einen Vertrag ab: Aufrufer müssen den Fehler behandeln. Der Compiler erzwingt es.

Einen leichtgewichtigen Result-Typ bauen

Du brauchst für die meisten Anwendungen kein fp-ts oder neverthrow. Eine minimale Implementierung deckt 90% der realen Anwendungsfälle ab.

tstypescript
type Result<T, E = string> = { ok: true; value: T } | { ok: false; error: E };
 
function ok<T>(value: T): Result<T, never> {
  return { ok: true, value };
}
 
function err<E>(error: E): Result<never, E> {
  return { ok: false, error };
}
 
// Type guard for direct discrimination
function isOk<T, E>(result: Result<T, E>): result is { ok: true; value: T } {
  return result.ok;
}

Die discriminated union bedeutet, dass TypeScript automatisch eingrenzt, wenn du result.ok prüfst. Kein Casting, keine as-Assertions — der Compiler verfolgt, in welchem Branch du dich befindest.

Wann werfen vs. wann zurückgeben

Nicht jeder Fehler sollte ein Result sein. Beides falsch zu mischen erzeugt Noise. Verwende diese Heuristik:

SzenarioAnsatzBegründung
Erwarteter Fehler (nicht gefunden, Validierungsfehler)Result<T, E>Aufrufer wird erwartet, ihn zu behandeln
Programmierer-Bug (Null-Dereferenzierung, falsche Annahme)throwSoll laut crashen — es ist ein Bug
Infrastrukturfehler (Netzwerk-Timeout, volle Festplatte)throw an der GrenzeRetry-Logik gehört an den Rand
Formularvalidierung für BenutzerResult<T, ValidationError[]>Mehrere Fehler, strukturiertes Feedback

Das Ziel ist, dass throw bedeutet: "Das sollte im korrekten Code nie passieren." Wenn es doch passiert, willst du es laut und nachvollziehbar.

Results komponieren

Die wahre Stärke zeigt sich, wenn du Operationen verkettest, die jeweils fehlschlagen können. Ohne einen Helper wird das zu verschachteltem Pattern Matching.

tstypescript
// Utility for chaining fallible operations
function andThen<T, U, E>(
  result: Result<T, E>,
  fn: (value: T) => Result<U, E>,
): Result<U, E> {
  if (!result.ok) return result;
  return fn(result.value);
}
 
// Usage: each step can fail, errors propagate automatically
async function processOrder(
  orderId: string,
): Promise<Result<Receipt, OrderError>> {
  const order = await fetchOrder(orderId);
  if (!order.ok) return order;
 
  const inventory = await checkInventory(order.value.items);
  if (!inventory.ok) return inventory;
 
  const payment = await chargePayment(order.value.total);
  if (!payment.ok) return payment;
 
  return ok(generateReceipt(order.value, payment.value));
}

Jeder Schritt hat einen typisierten Fehler. Der Aufrufer von processOrder sieht eine Union aller möglichen Fehler und behandelt jeden explizit.

Error Boundaries am Rand

Halte try/catch an Systemgrenzen. HTTP-Handler, Queue-Consumer und CLI-Einstiegspunkte sind der richtige Ort. Alles darin sind typisierte Results, die sauber propagieren.

tstypescript
// API route handler — the only place with try/catch
export async function POST(req: Request) {
  try {
    const body = await req.json();
    const result = await processOrder(body.orderId);
 
    if (!result.ok) {
      const statusMap: Record<OrderError, number> = {
        NOT_FOUND: 404,
        INVENTORY_UNAVAILABLE: 409,
        PAYMENT_FAILED: 402,
        DB_ERROR: 500,
      };
      return Response.json(
        { error: result.error },
        { status: statusMap[result.error] ?? 500 },
      );
    }
 
    return Response.json(result.value, { status: 201 });
  } catch (e) {
    // Truly unexpected — log and fail
    console.error("Unhandled error in POST /orders", e);
    return Response.json({ error: "INTERNAL_ERROR" }, { status: 500 });
  }
}

Infrastrukturfehler (try/catch) bleiben an der Grenze. Geschäftslogik-Fehler (Result) werden explizit behandelt. Die Unterscheidung macht den Code einfacher zu durchdenken und zu testen.

Fehlerpfade testen

Ein unterschätzter Vorteil von Result-Typen: Sie machen Fehlerpfade trivial testbar. Kein Mocking von throw-Verhalten nötig — einfach den passenden Fehlertyp zurückgeben.

tstypescript
// Testing is clean — no try/catch, no mock implementations that throw
describe("processOrder", () => {
  it("returns INVENTORY_UNAVAILABLE when stock is depleted", async () => {
    mockFetchOrder.mockResolvedValue(ok(sampleOrder));
    mockCheckInventory.mockResolvedValue(err("INVENTORY_UNAVAILABLE" as const));
 
    const result = await processOrder("order-123");
 
    expect(result.ok).toBe(false);
    expect(result.ok === false && result.error).toBe("INVENTORY_UNAVAILABLE");
  });
});

Vergleiche das mit dem Testen von Code, der wirft — du brauchst expect(...).rejects.toThrow(...), was Typinformationen verliert und zusätzlichen Aufwand erfordert.

Wichtige Erkenntnisse

  1. try/catch versteckt Fehler vor dem Typsystem — verwende Result<T, E> für erwartete Fehler, damit der Compiler sie verfolgt
  2. Discriminated unions geben dir zero-cost typisierte Fehler — keine Bibliothek nötig, kein Laufzeit-Overhead
  3. Reserviere throw für echte Programmierer-Bugs — wenn es ein behebbarer Zustand ist, sollte es ein Result sein
  4. Halte Error Boundaries an Systemgrenzen — HTTP-Handler und Queue-Consumer sind der richtige Ort für Infrastruktur-try/catch
  5. Typisierte Fehler erleichtern Tests — du kannst einfach Fehler-Results zurückgeben, ohne throw-Verhalten zu mocken
  6. Komponiere Results mit Helpern — das Verketten fehleranfälliger Operationen bleibt mit einer einfachen andThen-Utility lesbar
Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX