Einführung in Effect-TS: funktionale Fehlerbehandlung und Nebenwirkungen typisiert
AI generated
type
TypeScript · Funktionale Programmierung
Einführung in Effect-TS
Funktionale Fehlerbehandlung und Nebenwirkungen typisiert

Effect-TS macht mit dem Typ Effect sichtbar, was try-catch verschweigt: welche Fehler eine Operation werfen kann und welche Abhängigkeiten sie zur Ausführung benötigt. Beides wird Teil der Signatur, nicht nur eine Randnotiz in der Dokumentation.

11 Min. Lesezeit Effect-TS Fehlerbehandlung Funktionale Programmierung

1. Das Problem, das try-catch nicht löst

In TypeScript kann eine Funktion, die throw verwendet, jeden beliebigen Fehlertyp werfen, ohne dass dies in ihrer Signatur sichtbar wird. Der Rückgabetyp Promise verspricht einen Nutzer, verrät aber nichts darüber, ob dabei ein Netzwerkfehler, ein Validierungsfehler oder eine Datenbankausnahme auftreten kann. Aufrufer müssen sich auf Dokumentation oder Quellcode-Lektüre verlassen, um mögliche Fehlerfälle zu kennen.

Effect löst dieses Problem, indem der Fehlertyp Teil der Signatur selbst wird. Eine Operation vom Typ Effect beschreibt einen Berechnungsplan, der bei Erfolg einen Wert vom Typ A liefert, bei Fehlschlag einen typisierten Fehler vom Typ E erzeugt und dabei eine Umgebung vom Typ R benötigt. Alle drei Aspekte prüft der Compiler, keiner davon bleibt implizit.

Dieser Ansatz mag zunächst nach zusätzlicher Umständlichkeit klingen, zahlt sich aber gerade in größeren Codebasen aus, in denen Fehlerbehandlung sonst schnell zu einer Sammlung stillschweigender Annahmen verkommt, die nur im Kopf einzelner Entwickler existieren. Neue Teammitglieder profitieren besonders, weil die Signatur einer Funktion allein schon verrät, mit welchen Fehlerfällen beim Aufruf zu rechnen ist, ohne dass sie erst den Implementierungscode lesen müssen.


import { Effect } from 'effect';

class UserNotFoundError {
  readonly _tag = 'UserNotFoundError';
  constructor(readonly id: string) {}
}

function findUser(id: string): Effect.Effect<User, UserNotFoundError> {
  return Effect.gen(function* () {
    const user = await lookupUser(id);
    if (!user) {
      return yield* Effect.fail(new UserNotFoundError(id));
    }
    return user;
  });
}

2. Die drei Typparameter von Effect im Detail

Der erste Typparameter A entspricht dem Erfolgswert, vergleichbar mit dem Typparameter eines Promise. Der zweite Parameter E beschreibt sämtliche möglichen, erwarteten Fehlerfälle als Union-Typ, wodurch die Fehlerbehandlung an der Aufrufstelle vollständig und erschöpfend geprüft werden kann, ähnlich wie bei einem Result-Typ, nur automatisch propagiert.

Der dritte Parameter R beschreibt Abhängigkeiten, die zur Ausführung benötigt werden, etwa ein Datenbank-Client oder ein Logger. Ein Effect mit R = never ist vollständig eigenständig ausführbar, während ein Effect mit konkretem R-Typ erst nach Bereitstellung dieser Abhängigkeit läuft. Dieser Mechanismus ersetzt klassische Dependency Injection über Konstruktor-Parameter durch einen typisierten Umgebungskanal.

3. Effect.gen: sequenzieller Code ohne Pyramide aus pipe-Aufrufen

Effects lassen sich über Kombinatoren wie Effect.flatMap und Effect.map mit pipe() verketten, was bei mehreren aufeinanderfolgenden Schritten schnell unübersichtlich wird. Effect.gen löst dieses Problem, indem es Generator-Funktionen nutzt, um sequenziellen, imperativ wirkenden Code zu schreiben, der intern dennoch vollständig aus Effects besteht.

Innerhalb eines Generators markiert yield* jede Stelle, an der ein Effect ausgeführt und sein Ergebnis entpackt wird, vergleichbar mit await bei Promises. Schlägt einer der Schritte fehl, bricht die gesamte Generator-Funktion mit dem entsprechenden typisierten Fehler ab, ohne dass explizite Zwischenprüfungen nötig sind.


import { Effect } from 'effect';

const program = Effect.gen(function* () {
  const user = yield* findUser('u1');
  const posts = yield* findPostsByAuthor(user.id);
  const summary = yield* summarizePosts(posts);
  return { user, summary };
});

4. Den Fehlerkanal gezielt behandeln

Um auf typisierte Fehler zu reagieren, bietet Effect Funktionen wie Effect.catchTag, die anhand eines Diskriminanten-Felds, üblicherweise _tag, gezielt auf einen bestimmten Fehlertyp reagieren, während andere Fehlertypen unverändert weitergereicht werden. Das entspricht einem typsicheren switch über die Fehlerunion, nur ohne manuelles Pattern Matching im Aufrufercode.

Für Fälle, in denen ein Fehler tatsächlich behoben und der Ablauf fortgesetzt werden kann, liefert catchTag einen neuen Effect zurück, dessen Fehlerkanal um den behandelten Fehlertyp reduziert ist. Der Compiler erkennt dadurch exakt, welche Fehlerfälle nach der Behandlung noch offen sind und welche bereits abgedeckt wurden.


const safeProgram = program.pipe(
  Effect.catchTag('UserNotFoundError', (error) =>
    Effect.succeed({ user: null, summary: `Kein Nutzer mit ID ${error.id}` }),
  ),
);

5. Layer: typisierte Dependency Injection ohne Container

Abhängigkeiten werden in Effect über Context.Tag deklariert, ein eindeutiges Token für einen Dienst wie eine Datenbankverbindung, und über Layer-Werte konkret bereitgestellt. Ein Effect, das eine Abhängigkeit über yield* DatabaseService anfordert, trägt diese Anforderung automatisch im dritten Typparameter R, sodass der Compiler erzwingt, dass eine passende Implementierung bereitgestellt wird, bevor das Effect tatsächlich ausgeführt werden kann.

Layer lassen sich komponieren, sodass eine komplexe Anwendung aus vielen kleinen, unabhängig testbaren Diensten zusammengesetzt wird. Für Tests ersetzt man den echten DatabaseLayer einfach durch einen TestDatabaseLayer mit In-Memory-Implementierung, ohne dass die Geschäftslogik selbst angepasst werden muss.


import { Context, Effect, Layer } from 'effect';

class DatabaseService extends Context.Tag('DatabaseService')<
  DatabaseService,
  { findUser: (id: string) => Effect.Effect<User | null> }
>() {}

const DatabaseLive = Layer.succeed(DatabaseService, {
  findUser: (id) => Effect.promise(() => db.user.findUnique({ where: { id } })),
});

const program = Effect.gen(function* () {
  const db = yield* DatabaseService;
  return yield* db.findUser('u1');
});

Effect.runPromise(program.pipe(Effect.provide(DatabaseLive)));

6. Das Schema-Modul: Validierung mit demselben Fehlermodell

Effect bringt mit dem Schema-Modul eine eigene Validierungsbibliothek mit, die sich nahtlos in das gleiche Effect-basierte Fehlermodell einfügt, statt eine externe Bibliothek wie Zod separat zu behandeln. Ein Schema.decodeUnknown liefert ein Effect zurück, dessen Fehlerkanal ParseError enthält, das sich genauso mit catchTag oder Effect.gen verarbeiten lässt wie jeder andere typisierte Fehler.

Der Vorteil gegenüber einer separaten Validierungsbibliothek liegt in der einheitlichen Fehlerbehandlung: Ein Validierungsfehler, ein Datenbankfehler und ein Netzwerkfehler landen alle im selben strukturierten Fehlerkanal und lassen sich mit denselben Kombinatoren behandeln, statt drei unterschiedliche Fehlermodelle im selben Programm zu mischen.


import { Schema, Effect } from 'effect';

const UserInput = Schema.Struct({
  email: Schema.String.pipe(Schema.pattern(/^\S+@\S+$/)),
  name: Schema.String.pipe(Schema.minLength(1)),
});

const parseUserInput = (raw: unknown) => Schema.decodeUnknown(UserInput)(raw);

7. Wiederholung und Timeout als typisierte Kombinatoren

Robustheit gegenüber vorübergehenden Fehlern wird in Effect nicht durch verschachtelte try-catch-Schleifen erreicht, sondern durch deklarative Kombinatoren wie Effect.retry und Effect.timeout, die auf ein bestehendes Effect angewendet werden. Eine Retry-Policy beschreibt dabei präzise, wie oft und mit welchem Backoff-Verhalten wiederholt werden soll.

Weil diese Kombinatoren selbst wieder ein Effect zurückgeben, lassen sie sich beliebig mit anderen Effects kombinieren, etwa einer Datenbankabfrage, die nach einem Timeout automatisch abgebrochen und mit exponentiellem Backoff bis zu drei Mal wiederholt wird, komplett deklarativ ohne verschachtelte Schleifenlogik.


import { Effect, Schedule, Duration } from 'effect';

const resilientFetch = fetchRemoteData.pipe(
  Effect.timeout(Duration.seconds(5)),
  Effect.retry(Schedule.exponential(Duration.millis(200)).pipe(Schedule.compose(Schedule.recurs(3)))),
);

8. Effect im Vergleich zu leichtgewichtigen Result-Bibliotheken

Leichtgewichtige Bibliotheken wie neverthrow bilden mit einem Result-Typ einen ähnlichen Grundgedanken ab, typisierte Fehler statt geworfener Exceptions, bleiben dabei aber bewusst auf synchrone und asynchrone Wertberechnung beschränkt. Effect geht deutlich weiter und deckt zusätzlich Nebenwirkungsmanagement, Nebenläufigkeit über Fibers, Dependency Injection und Ressourcenverwaltung ab.

Für kleinere Projekte oder eine schrittweise Migration reicht ein leichtgewichtiger Result-Typ oft aus und verursacht weniger Lernaufwand. Effect lohnt sich, sobald eine Anwendung ohnehin komplexe Nebenläufigkeit, mehrschichtige Abhängigkeiten oder anspruchsvolle Fehlerbehandlungsstrategien benötigt, die sonst mit viel Handarbeit nachgebaut werden müssten.

9. Effects ausführen: von der Beschreibung zur tatsächlichen Wirkung

Ein Effect-Wert ist zunächst nur eine Beschreibung einer Berechnung, keine bereits ausgeführte Aktion. Erst Funktionen wie Effect.runPromise, Effect.runSync oder Effect.runFork lösen die tatsächliche Ausführung aus, jeweils passend zum Kontext, in dem das Ergebnis benötigt wird, etwa als Promise am Rand einer Node.js-Anwendung oder synchron innerhalb eines bereits laufenden Kontexts.

Diese strikte Trennung zwischen Beschreibung und Ausführung erlaubt es, Effects wie gewöhnliche Werte zu komponieren, zu testen und wiederzuverwenden, bevor überhaupt eine Nebenwirkung stattfindet. Das unterscheidet sich fundamental von einer async-Funktion, die bei jedem Aufruf sofort zu laufen beginnt, unabhängig davon, ob ihr Ergebnis tatsächlich gebraucht wird.

Merkmal try-catch Result-Typ (z. B. neverthrow) Effect-TS
Fehlertyp in Signatur Nicht sichtbar Explizit als Typparameter Explizit als zweiter Typparameter E
Abhängigkeiten typisiert Nein Nein Ja, über dritten Typparameter R
Nebenläufigkeit Manuell mit Promise.all Manuell mit Promise.all Eingebaute Fiber-basierte Nebenläufigkeit
Retry/Timeout Handgeschriebene Schleifen Handgeschriebene Schleifen Deklarative Kombinatoren
Lernkurve Niedrig Niedrig bis mittel Hoch, eigenes mentales Modell

Mironsoft

TypeScript-Migration, Typsicherheit und Team-Onboarding

JavaScript-Codebasis ohne Typsicherheit, aber keine Zeit für eine Rundum-Migration?

Wir migrieren bestehende JavaScript-Projekte schrittweise zu TypeScript, richten strikte Compiler-Einstellungen sauber ein und bringen Teams mit Code-Reviews und Style-Guides auf denselben Typsicherheits-Stand.

Migrations-Fahrplan

Schrittweise JS-zu-TS-Migration ohne Big-Bang-Risiko planen und umsetzen.

Strict-Mode-Einführung

tsconfig.json, ESLint-Regeln und CI-Checks für dauerhafte Typsicherheit aufsetzen.

Team-Onboarding

Entwickler mit Workshops und Code-Reviews in TypeScript-Best-Practices einarbeiten.

10. Zusammenfassung

Einführung in Effect-TS

Kerntyp

Effect macht Erfolg, Fehler und Abhängigkeit sichtbar

Sequenzieller Code

Effect.gen mit yield* wirkt wie async/await, bleibt aber typisiert

Dependency Injection

Context.Tag und Layer ersetzen manuelle Konstruktor-Injection

Resilienz

Effect.retry und Effect.timeout als deklarative Kombinatoren

11. FAQ: Einführung in Effect-TS

1Was löst Effect-TS, das try-catch nicht kann?
try-catch verrät nichts über die möglichen Fehlertypen einer Funktion, diese bleiben implizit. Effect macht Fehler über den zweiten Typparameter E einer Effect-Signatur explizit sichtbar und vom Compiler prüfbar.
2Wofür steht der dritte Typparameter R bei Effect?
R beschreibt die Abhängigkeiten, die zur Ausführung des Effects benötigt werden, etwa einen Datenbank-Client. Ein Effect mit R = never ist vollständig eigenständig ausführbar, ein Effect mit konkretem R-Typ benötigt eine bereitgestellte Implementierung.
3Was macht Effect.gen anders als verkettete pipe-Aufrufe?
Effect.gen nutzt Generator-Funktionen, um sequenziellen, imperativ wirkenden Code zu schreiben. yield* entpackt dabei jeden Effect-Schritt, vergleichbar mit await bei Promises, bleibt aber vollständig typisiert.
4Wie behandelt man einen bestimmten Fehlertyp gezielt?
Mit Effect.catchTag, das anhand eines Diskriminanten-Felds wie _tag gezielt auf einen Fehlertyp reagiert. Der Compiler erkennt danach, welche Fehlerfälle im Fehlerkanal bereits behandelt wurden.
5Was ist ein Layer in Effect-TS?
Ein Layer stellt eine konkrete Implementierung für einen über Context.Tag deklarierten Dienst bereit. Layer lassen sich komponieren und für Tests durch alternative Implementierungen ersetzen, ohne die Geschäftslogik anzupassen.
6Ersetzt das Schema-Modul eine externe Validierungsbibliothek wie Zod?
Effect bringt mit Schema eine eigene Validierungsbibliothek mit, deren Fehler in denselben Effect-Fehlerkanal fließen wie andere Fehler im Programm. Zod bleibt außerhalb von Effect-Projekten weiterhin eine valide, eigenständige Wahl.
7Wie funktioniert Retry-Logik in Effect ohne Schleifen?
Über deklarative Kombinatoren wie Effect.retry in Kombination mit einer Schedule-Policy, die Anzahl der Versuche und Backoff-Verhalten beschreibt. Das Ergebnis ist selbst wieder ein Effect, das sich weiter kombinieren lässt.
8Wann lohnt sich Effect-TS gegenüber einer leichtgewichtigen Result-Bibliothek?
Sobald eine Anwendung zusätzlich zu typisierten Fehlern auch komplexe Nebenläufigkeit, mehrschichtige Abhängigkeiten oder deklarative Resilienzstrategien wie Retry und Timeout benötigt, die sonst manuell nachgebaut werden müssten.
9Wird ein Effect sofort ausgeführt, sobald es erzeugt wird?
Nein, ein Effect-Wert ist zunächst nur eine Beschreibung einer Berechnung. Erst Funktionen wie Effect.runPromise oder Effect.runSync lösen die tatsächliche Ausführung aus.
10Ist Effect-TS mit bestehendem Promise-basiertem Code kompatibel?
Ja, über Effect.promise und Effect.tryPromise lassen sich bestehende Promises in ein Effect überführen, und Effect.runPromise liefert am Rand der Anwendung wieder ein normales Promise zurück.