Zum Inhalt springen

Die wahre Stärke von Swift 6's Typed Throws mit Fehlerketten entfesseln

Erfahre, wie du Typed Throws vom Kopfzerbrechen zur Superkraft machst – mit sauberer Fehlerbehandlung und mächtigen Debugging-Einblicken.

Die wahre Stärke von Swift 6's Typed Throws mit Fehlerketten entfesseln

Swift 6 hat endlich eines der meistgewünschten Features in Swift eingeführt: Typed Throws. Diese Verbesserung erlaubt es dir, genau anzugeben, welche Fehlertypen eine Funktion werfen kann, und bringt damit Swifts Typsicherheit in die Fehlerbehandlung. Aber mit dieser Mächtigkeit kommt eine neue Herausforderung, die ich “Verschachtelungshölle” nennen würde – ein Problem, das beeinflusst, wie Fehler sich durch die Schichten deiner Anwendung verbreiten.

In diesem Beitrag erkläre ich das Verschachtelungsproblem und zeige dir, wie ich es in ErrorKit mit einem einfachen Protocol gelöst habe, das Typed Throws ohne Boilerplate praxistauglich macht. Als Bonus siehst du, wie richtige Fehlerketten dein Debugging-Erlebnis dramatisch verbessern können.

Typed Throws: Das Versprechen und das Problem

Schauen wir uns zuerst an, was uns Typed Throws in Swift 6 bietet:

// Statt nur 'throws' können wir den Fehlertyp angeben
func processFile() throws(FileError) {
    if !fileExists {
        throw FileError.fileNotFound(fileName: "config.json")
    }
    // Implementierung...
}

Das ermöglicht eine bessere Fehlerbehandlung an der Aufrufstelle:

do {
    try processFile()
} catch FileError.fileNotFound(let fileName) {
    print("Could not find file: \(fileName)")
} catch FileError.readFailed {
    print("Could not read file")
}
// Kein generischer catch nötig, wenn wir alle möglichen FileError-Fälle behandelt haben!

Die Vorteile liegen auf der Hand:

  • Compile-time-Verifizierung der Fehlerbehandlung

  • Kein Type-Casting mit as? in Catch-Blöcken nötig

  • Selbstdokumentierende API, die Aufrufern genau sagt, was schiefgehen kann

  • IDE-Autovervollständigung für Fehlerfälle

Das Verschachtelungshölle-Problem

Das Problem entsteht bei der Arbeit mit mehrschichtigen Anwendungen. Sieh dir das an:

// Datenbankschicht wirft DatabaseError
func fetchUser(id: String) throws(DatabaseError) {
    // Datenbankoperationen...
}

// Profilschicht muss die Datenbankschicht aufrufen
func loadUserProfile(id: String) throws(ProfileError) {
    do {
        // Problem: Dies wirft DatabaseError, nicht ProfileError
        let user = try fetchUser(id: id)
    } catch {
        // Manuelle Fehlerkonvertierung nötig
        switch error {
        case DatabaseError.recordNotFound:
            throw ProfileError.userNotFound
        default:
            throw ProfileError.databaseError(error) // Ein Wrapper-Case wird benötigt
        }
    }
}

Das erzeugt mehrere Probleme:

  1. Explosion von Wrapper-Cases: Jeder Fehlertyp braucht Wrapper-Cases für alle möglichen Kind-Fehler

  2. Manuelles Error-Mapping: Repetitive Do-Catch-Blöcke mit expliziter Fehlerkonvertierung

  3. Typen-Inflation: Fehlertypen wachsen mit jeder Schicht und werden schwerer zu pflegen

  4. Verlorener Kontext: Details über den ursprünglichen Fehler gehen bei der Übersetzung oft verloren

Für kleine Apps mag das handhabbar sein. Für größere Apps mit vielen Schichten wird es schnell zu dem, was man als “Verschachtelungshölle” bezeichnen kann.

Die Lösung: Das Catching-Protocol

ErrorKit löst das mit einem einfachen Protocol namens Catching:

public protocol Catching {
    static func caught(_ error: Error) -> Self
}

Dieses Protocol erfordert einen einzelnen Enum-Case namens caught, der jeden Fehler in deinen Typ einwickelt. So verwendest du es:

enum ProfileError: Throwable, Catching {
    case userNotFound
    case invalidProfile
    case caught(Error) // Ein einziger Case für alle anderen Fehler

    var userFriendlyMessage: String {
        switch self {
        case .userNotFound:
            return "User not found."
        case .invalidProfile:
            return "Profile data is invalid."
        case .caught(let error):
            // Die Meldung des eingewickelten Fehlers verwenden
            return ErrorKit.userFriendlyMessage(for: error)
        }
    }
}

Beachte, dass Throwable ein Drop-in-Ersatz für Error ist (siehe vorheriger Beitrag).

Jetzt passiert die Magie mit der catch-Funktion, die mit dem Protocol mitgeliefert wird:

func loadUserProfile(id: String) throws(ProfileError) {
    // Bei bekannten Fehlern direkt werfen
    guard isValidID(id) else {
        throw ProfileError.invalidInput
    }

    // Für Operationen, die andere Fehlertypen werfen können, die catch-Funktion verwenden
    let user = try ProfileError.catch {
        // Jeder hier geworfene Fehler wird automatisch
        // in ProfileError.caught(error) eingewickelt
        return try fetchUser(id: id)
    }

    // Rest der Implementierung...
}

Beachte, dass die catch-Funktion zurückgibt, was du in der Closure zurückgibst.

Die catch-Funktion wickelt automatisch alle Fehler, die in ihrer Closure geworfen werden, in deinen Fehlertyp ein. Keine manuellen Do-Catch-Blöcke, kein explizites Error-Mapping – es funktioniert einfach. Sogar mehrere try-Ausdrücke sind möglich.

Das Geheimrezept der catch-Funktion

Die catch-Funktion ist elegant einfach:

extension Catching {
    public static func `catch`<ReturnType>(
        _ operation: () throws -> ReturnType
    ) throws(Self) -> ReturnType {
        do {
            return try operation()
        } catch {
            throw Self.caught(error)
        }
    }
}

Diese Funktion:

  1. Nimmt eine werfende Closure entgegen

  2. Versucht, sie auszuführen

  3. Gibt das Ergebnis bei Erfolg zurück

  4. Wickelt jeden geworfenen Fehler automatisch über deinen caught-Case ein

  5. Behält den Rückgabetyp der Operation bei

Das Beste daran? Es funktioniert nahtlos mit Swift 6’s Typed Throws, erhält die Typsicherheit und eliminiert gleichzeitig Boilerplate.

Die Fehlerkette für Debugging erhalten

Einer der größten Vorteile dieses Ansatzes ist, dass er die vollständige Fehlerkette erhält. Statt Kontext zu verlieren, wenn Fehler Schichtgrenzen überschreiten, fügt jede Schicht Informationen hinzu und behält den ursprünglichen Fehler intakt.

ErrorKit nutzt das für mächtiges Debugging mit der errorChainDescription(for:)-Funktion:

do {
    try await updateUserProfile()
} catch {
    print(ErrorKit.errorChainDescription(for: error))

    // Ausgabe zeigt die vollständige Kette:
    // AppError
    // └─ ProfileError
    //    └─ DatabaseError
    //       └─ FileError.notFound(path: "/Users/data.db")
    //          └─ userFriendlyMessage: "Could not find database file."
}

Diese hierarchische Ansicht zeigt dir:

  1. Wo der Fehler entstanden ist (FileError)

  2. Den genauen Weg durch deine Anwendung (FileError -> DatabaseError -> ProfileError -> AppError)

  3. Die konkreten Details, was schiefgelaufen ist (Datei nicht gefunden, mit dem Pfad)

  4. Die benutzerfreundliche Meldung, die dem Nutzer angezeigt würde

Dieses Maß an Einblick ist beim Debugging unbezahlbar, besonders bei komplexen Anwendungen, in denen Fehler tief im Call Stack entstehen können.

Strukturierte Fehlerketten-Ausgabe

Die Fehlerketten-Beschreibung funktioniert durch rekursives Inspizieren der Fehlerstruktur:

static func errorChainDescription(for error: Error) -> String {
    // Rekursive Implementierung, die eine hierarchische Beschreibung aufbaut
    Self.chainDescription(for: error, enclosingType: type(of: error))
}

Siehe hier für die vollständige Implementierung von chainDescription in ErrorKit.

Die Funktion nutzt Swifts Reflection-Fähigkeiten, um:

  1. Den Fehler über die Mirror-API zu inspizieren

  2. Bei Fehlern, die Catching konformieren, den eingewickelten Fehler zu extrahieren

  3. Bei Enum-Fehlern Case-Namen und zugehörige Werte zu erfassen

  4. Bei Struct- oder Class-Fehlern Typ-Metadaten einzubeziehen

  5. Alles in einer hierarchischen Baumstruktur zu formatieren

Das liefert weit mehr Informationen als Standard-Error-Logging, besonders bei komplexen Fehlerhierarchien.

Eingebaute Unterstützung in ErrorKit

Alle eingebauten Fehlertypen von ErrorKit (wie FileError oder NetworkError) konformieren bereits zu Catching, sodass du sie sofort verwenden kannst:

func saveUserData() throws(DatabaseError) {
    // Wickelt automatisch SQLite-Fehler, Dateisystem-Fehler usw. ein
    try DatabaseError.catch {
        try database.beginTransaction()
        try database.execute(query)
        try database.commit()
    }
}

Praxisbeispiel: Eine typische Anwendung

Schauen wir uns an, wie das in einem vollständigeren Beispiel funktioniert:

// Datenzugriffsschicht
func fetchUserData(id: String) throws(DatabaseError) {
    guard database.isConnected else {
        throw DatabaseError.connectionFailed
    }

    // Hier könnten Dateisystem-Fehler auftreten
    try DatabaseError.catch {
        let query = try QueryBuilder.build(for: id)
        return try database.execute(query)
    }
}

// Geschäftslogik-Schicht
func processUserProfile(id: String) throws(ProfileError) {
    guard isValidID(id) else {
        throw ProfileError.invalidInput
    }

    // Wickelt automatisch DatabaseError ein
    let userData = try ProfileError.catch {
        return try fetchUserData(id: id)
    }

    // Nutzerdaten verarbeiten...
}

// Präsentationsschicht
func displayUserProfile(id: String) throws(UIError) {
    // Wickelt automatisch ProfileError ein (das möglicherweise DatabaseError enthält)
    let profile = try UIError.catch {
        return try processUserProfile(id: id)
    }

    // Profil anzeigen...
}

Wenn eine Datenbankverbindung fehlschlägt, siehst du Folgendes in der Fehlerkette:

UIError
└─ ProfileError
   └─ DatabaseError.connectionFailed
      └─ userFriendlyMessage: "Unable to establish a connection to the database. Check your network settings and try again."

Das zeigt dir genau, was passiert ist und wo der Fehler entstanden ist, was das Debugging erheblich erleichtert. Der zusätzliche Kontext kann dir den entscheidenden Hinweis geben, um das Problem zu beheben!

Fazit

Swift 6’s Typed Throws ist eine mächtige Ergänzung der Sprache, bringt aber Herausforderungen bei der Fehlerweiterleitung über Schichten hinweg mit sich. Das Catching-Protocol bietet eine einfache, elegante Lösung, die Typsicherheit erhält und gleichzeitig Boilerplate eliminiert.

Kombiniert mit ErrorKits errorChainDescription-Funktion wird Fehlerbehandlung zu einem mächtigen Debugging-Werkzeug. Nutze ErrorKit jetzt und profitiere von vielen weiteren Verbesserungen, die Fehlerbehandlung in Swift in realen Apps nützlicher machen:

github.comFlineDev / ErrorKitSimplified error handling with built-in user-friendly messages for common errors. Fully localized. Community-driven

Hast du schon angefangen, Swift 6’s Typed Throws zu nutzen? Wie gehst du mit der Fehlerweiterleitung über Schichten in deinen Apps um? Schreib mir auf den sozialen Kanälen (Links unten)!

Vorheriger Artikel in dieser Serie:

  1. Swift Error Handling Done Right: Overcoming the ObjC Legacy

Folgende Artikel in dieser Serie:

  1. Better Error Reporting in Swift Apps: Automatic Logs + Analytics

  2. Making Swift Error Messages Human-Friendly—Together

Hat dir dieser Beitrag gefallen? Folge mir auf Bluesky und Mastodon für mehr Swift-Tipps und Indie-Dev-Updates.