Wie funktionale Patterns die Art verändern, wie wir mit Fehlern und Abwesenheit umgehen
SerieSprachtheorie für Praktiker
Teil 2 von 6
null wurde von seinem Erfinder Tony Hoare als „Billion Dollar Mistake” bezeichnet. Exceptions galten lange als elegante Lösung, haben aber ihre eigenen Probleme: Sie sind unsichtbar im Typsystem, können vergessen werden, und machen Code schwer nachvollziehbar.
Die funktionale Programmierung hatte schon immer eine Alternative: Algebraische Datentypen (ADTs). Und nach Jahrzehnten im akademischen Schatten sind sie endlich im Mainstream angekommen.
Was sind Algebraische Datentypen?
ADTs kombinieren zwei Grundbausteine:
Produkttypen (AND): Kombinieren mehrere Werte zu einem Ganzen
type User = { name: string; age: number }; // name UND age
Summentypen (OR): Einer von mehreren möglichen Varianten
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E }; // Erfolg ODER Fehler
Die Kombination dieser beiden Konzepte ermöglicht präzise Modellierung von Domänenlogik.
Option: Die Abwesenheit typisieren
Statt null oder undefined zu verwenden, macht Option (auch Maybe genannt) die mögliche Abwesenheit explizit:
Rust
fn find_user(id: u32) -> Option<User> {
users.get(&id).cloned()
}
// Nutzung erzwingt Handling
match find_user(42) {
Some(user) => println!("Found: {}", user.name),
None => println!("User not found"),
}
Swift
func findUser(id: Int) -> User? {
users[id]
}
// Optional Chaining
let name = findUser(id: 42)?.name ?? "Unknown"
// Guard für Early Return
guard let user = findUser(id: 42) else {
return
}
TypeScript
type Option<T> = { _tag: 'Some'; value: T } | { _tag: 'None' };
function findUser(id: number): Option<User> {
const user = users.get(id);
return user ? { _tag: 'Some', value: user } : { _tag: 'None' };
}
Result: Fehler als Werte
Result (auch Either genannt) geht einen Schritt weiter: Statt Exceptions zu werfen, werden Fehler als Werte zurückgegeben.
Rust
fn parse_config(path: &str) -> Result<Config, ConfigError> {
let content = fs::read_to_string(path)?;
let config: Config = serde_json::from_str(&content)?;
Ok(config)
}
// Der ?-Operator propagiert Fehler elegant
fn setup() -> Result<App, SetupError> {
let config = parse_config("config.json")?;
let db = connect_database(&config.db_url)?;
Ok(App { config, db })
}
Swift
enum ConfigError: Error {
case fileNotFound
case parseError(String)
}
func parseConfig(path: String) throws -> Config {
guard let content = try? String(contentsOfFile: path) else {
throw ConfigError.fileNotFound
}
return try JSONDecoder().decode(Config.self, from: content.data(using: .utf8)!)
}
Kotlin
sealed class Result<out T, out E> {
data class Ok<T>(val value: T) : Result<T, Nothing>()
data class Err<E>(val error: E) : Result<Nothing, E>()
}
fun parseConfig(path: String): Result<Config, ConfigError> {
val file = File(path)
if (!file.exists()) return Result.Err(ConfigError.FileNotFound)
return try {
Result.Ok(Json.decodeFromString(file.readText()))
} catch (e: Exception) {
Result.Err(ConfigError.ParseError(e.message ?: "Unknown"))
}
}
TypeScript
type Result<T, E> =
| { ok: true; value: T }
| { ok: false; error: E };
function parseConfig(path: string): Result<Config, ConfigError> {
try {
const content = readFileSync(path, 'utf-8');
return { ok: true, value: JSON.parse(content) };
} catch (e) {
return { ok: false, error: { type: 'ParseError', message: String(e) } };
}
}
// Pattern Matching mit Exhaustiveness Check
function handleResult(result: Result<Config, ConfigError>) {
if (result.ok) {
console.log(result.value); // TypeScript weiß: result.value existiert
} else {
console.error(result.error); // TypeScript weiß: result.error existiert
}
}
Discriminated Unions: Zustandsmodellierung
ADTs eignen sich hervorragend für endliche Zustandsautomaten:
type LoadingState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
function renderUser(state: LoadingState<User>) {
switch (state.status) {
case 'idle':
return <button>Load User</button>;
case 'loading':
return <Spinner />;
case 'success':
return <UserCard user={state.data} />;
case 'error':
return <ErrorMessage error={state.error} />;
}
}
Der Compiler garantiert:
- Alle Zustände werden behandelt
- In jedem Branch sind nur die passenden Properties verfügbar
- Neue Zustände erzwingen Updates an allen Switch-Statements
Vorteile gegenüber Exceptions
| Aspekt | Exceptions | Result/Option |
|---|---|---|
| Sichtbarkeit | Unsichtbar im Typsystem | Teil der Signatur |
| Vergesslichkeit | Leicht zu vergessen | Compiler erzwingt Handling |
| Kontrollfluss | Springt aus der Funktion | Bleibt im normalen Fluss |
| Komposition | Schwer zu kombinieren | map, flatMap, fold |
| Performance | Stack Unwinding | Normale Rückgabe |
Best Practices
1. Exceptions für echte Ausnahmen
// Gut: Erwartbarer Fehler als Result
function parseJson(input: string): Result<Data, ParseError>
// Gut: Unerwarteter Fehler als Exception
function processData(data: Data): ProcessedData {
if (invariantViolated) {
throw new Error("Invariant violated - this should never happen");
}
}
2. Railway-Oriented Programming
const result = parseConfig(path)
.map(config => validateConfig(config))
.flatMap(config => connectDatabase(config))
.map(db => createApp(db));
3. Early Return mit Guard Clauses
fn process(input: Input) -> Result<Output, Error> {
let validated = validate(input)?;
let transformed = transform(validated)?;
let result = finalize(transformed)?;
Ok(result)
}
Die Konvergenz
Was auffällt: Alle modernen Sprachen konvergieren in Richtung ADTs. TypeScript hat Discriminated Unions, Kotlin hat Sealed Classes, Swift hat Enums mit Associated Values, Java bekommt Pattern Matching.
Das ist kein Zufall. ADTs lösen ein fundamentales Problem der Softwareentwicklung: Sie machen implizites Wissen explizit. Der Typ sagt nicht nur, was ein Wert ist, sondern welche Zustände möglich sind und wie man mit ihnen umgeht.
Weiterführende Ressourcen
- Rust Book: Error Handling
- TypeScript Handbook: Narrowing
- Railway Oriented Programming – Scott Wlaschin
- Parse, don’t validate – Alexis King