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.
Inhaltsverzeichnis
- 1. Das Problem, das try-catch nicht löst
- 2. Die drei Typparameter von Effect im Detail
- 3. Effect.gen: sequenzieller Code ohne Pyramide aus pipe-Aufrufen
- 4. Den Fehlerkanal gezielt behandeln
- 5. Layer: typisierte Dependency Injection ohne Container
- 6. Das Schema-Modul: Validierung mit demselben Fehlermodell
- 7. Wiederholung und Timeout als typisierte Kombinatoren
- 8. Effect im Vergleich zu leichtgewichtigen Result-Bibliotheken
- 9. Effects ausführen: von der Beschreibung zur tatsächlichen Wirkung
- 10. Zusammenfassung
- 11. FAQ
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
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