Zum Inhalt springen
CASOON

Algebraische Datentypen im Mainstream: Result, Option und die Kunst der Fehlerbehandlung

Wie funktionale Patterns die Art verändern, wie wir mit Fehlern und Abwesenheit umgehen

11 Minuten
Algebraische Datentypen im Mainstream: Result, Option und die Kunst der Fehlerbehandlung
#Algebraische Datentypen #TypeScript #Rust #Swift
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

AspektExceptionsResult/Option
SichtbarkeitUnsichtbar im TypsystemTeil der Signatur
VergesslichkeitLeicht zu vergessenCompiler erzwingt Handling
KontrollflussSpringt aus der FunktionBleibt im normalen Fluss
KompositionSchwer zu kombinierenmap, flatMap, fold
PerformanceStack UnwindingNormale 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