コンテンツへスキップ

Swift 6のTyped Throwsの真の力をError Chainで引き出す

Typed Throwsを頭痛の種からスーパーパワーに変える方法を紹介します。クリーンなエラー処理と強力なデバッグインサイトを手に入れましょう。

Swift 6のTyped Throwsの真の力をError Chainで引き出す

Swift 6で、待望の機能がついにSwiftに導入されました:Typed Throwsです。この改善により、関数がスローできるエラー型を正確に指定でき、Swiftの型安全性がエラー処理にも拡張されます。しかし、この強力な機能には新たな課題が伴います。私はこれを「ネスト地獄」と呼んでいます――アプリケーションのレイヤーをまたいでエラーを伝播させる際に発生する問題です。

この記事では、このネスト問題を解説し、ErrorKit でシンプルなプロトコルを使ってどのように解決したかをお見せします。おまけとして、適切なエラーチェーンがデバッグ体験を劇的に改善する様子もご紹介します。

Typed Throws:期待と問題

まず、Swift 6でTyped Throwsが何を可能にするか見てみましょう:

// Instead of just 'throws', we can specify the error type
func processFile() throws(FileError) {
    if !fileExists {
        throw FileError.fileNotFound(fileName: "config.json")
    }
    // Implementation...
}

これにより、呼び出し側でより良いエラー処理が可能になります:

do {
    try processFile()
} catch FileError.fileNotFound(let fileName) {
    print("Could not find file: \(fileName)")
} catch FileError.readFailed {
    print("Could not read file")
}
// No generic catch needed if we've handled all possible FileError cases!

メリットは明確です:

  • エラー処理のコンパイル時検証

  • catchブロックで as? によるキャストが不要

  • 何が問題になりうるかを呼び出し側に正確に伝える自己文書化API

  • エラーケースのIDEオートコンプリート

ネスト地獄の問題

問題は、マルチレイヤーのアプリケーションで作業するときに発生します。こちらをご覧ください:

// Database layer throws DatabaseError
func fetchUser(id: String) throws(DatabaseError) {
    // Database operations...
}

// Profile layer needs to call the database layer
func loadUserProfile(id: String) throws(ProfileError) {
    do {
        // ⚠️ Problem: This throws DatabaseError, not ProfileError
        let user = try fetchUser(id: id)
    } catch {
        // Manual error conversion needed
        switch error {
        case DatabaseError.recordNotFound:
            throw ProfileError.userNotFound
        default:
            throw ProfileError.databaseError(error) // Need a wrapper case
        }
    }
}

これにはいくつかの問題があります:

  1. ラッパーケースの爆発的増加: すべてのエラー型に、想定される子エラー用のラッパーケースが必要になります

  2. 手動のエラーマッピング: 明示的なエラー変換を伴うdo-catchブロックの繰り返し

  3. 型の増殖: レイヤーが増えるごとにエラー型が肥大化し、メンテナンスが困難に

  4. コンテキストの喪失: 元のエラーの詳細が変換の過程で失われがち

小さなアプリなら対処可能かもしれません。しかし、多くのレイヤーを持つ大規模アプリでは、あっという間に「ネスト地獄」に陥ります。

解決策:Catching プロトコル

ErrorKitはこの問題を、Catching というシンプルなプロトコルで解決します:

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

このプロトコルは、任意のエラーを自分の型にラップする caught という単一のenumケースを要求します。使い方はこうです:

enum ProfileError: Throwable, Catching {
    case userNotFound
    case invalidProfile
    case caught(Error) // Single case for all other errors

    var userFriendlyMessage: String {
        switch self {
        case .userNotFound:
            return "User not found."
        case .invalidProfile:
            return "Profile data is invalid."
        case .caught(let error):
            // Use the wrapped error's message
            return ErrorKit.userFriendlyMessage(for: error)
        }
    }
}

ThrowableError のドロップイン置き換えです(前回の記事を参照)。

ここからが本番です。プロトコルに付属する catch 関数を使います:

func loadUserProfile(id: String) throws(ProfileError) {
    // For known errors, throw them directly
    guard isValidID(id) else {
        throw ProfileError.invalidInput
    }

    // For operations that may throw other error types, use the catch function
    let user = try ProfileError.catch {
        // Any error thrown here will be automatically wrapped
        // into ProfileError.caught(error)
        return try fetchUser(id: id)
    }

    // Rest of implementation...
}

catch 関数はクロージャ内で返した値をそのまま返します。

catch 関数は、クロージャ内でスローされたエラーを自動的にあなたのエラー型にラップします。手動のdo-catchブロックも、明示的なエラーマッピングも不要です。複数の try 式も問題ありません。

catch 関数の仕組み

catch 関数はエレガントなほどシンプルです:

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

この関数は:

  1. スロー可能なクロージャを受け取り

  2. その実行を試み

  3. 成功すれば結果を返し

  4. スローされたエラーを caught ケースで自動的にラップし

  5. 操作の戻り値の型を保持します

最も重要なのは、Swift 6のTyped Throwsとシームレスに連携し、ボイラープレートを排除しつつ型安全性を維持することです。

デバッグのためのエラーチェーン保持

このアプローチの最大のメリットの一つは、完全なエラーチェーンが保持されることです。エラーがレイヤーの境界を越える際にコンテキストが失われるのではなく、各レイヤーが情報を追加しながら元のエラーもそのまま保持します。

ErrorKitはこれを活用し、errorChainDescription(for:) 関数で強力なデバッグ機能を提供します:

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

    // Output shows the complete chain:
    // AppError
    // └─ ProfileError
    //    └─ DatabaseError
    //       └─ FileError.notFound(path: "/Users/data.db")
    //          └─ userFriendlyMessage: "Could not find database file."
}

この階層表示から以下のことがわかります:

  1. エラーの発生元(FileError)

  2. アプリケーション内を通過した正確な経路(FileError → DatabaseError → ProfileError → AppError)

  3. 何が問題だったかの具体的な詳細(ファイルが見つからない、パス付き)

  4. ユーザーに表示されるユーザーフレンドリーなメッセージ

このレベルのインサイトは、デバッグ時に非常に価値があります。特に、エラーがコールスタックの奥深くで発生する複雑なアプリケーションではなおさらです。

構造化されたエラーチェーン出力

エラーチェーンの記述は、エラー構造を再帰的に検査することで機能します:

static func errorChainDescription(for error: Error) -> String {
    // Recursive implementation that builds a hierarchical description
    Self.chainDescription(for: error, enclosingType: type(of: error))
}

chainDescription の完全な実装はこちらで確認できます。

この関数はSwiftのリフレクション機能を使って以下を行います:

  1. Mirror APIを使ってエラーを検査

  2. Catching に準拠するエラーからラップされたエラーを抽出

  3. enumエラーのケース名と関連値をキャプチャ

  4. structやclassエラーの型メタデータを含める

  5. すべてを階層的なツリー構造にフォーマット

これにより、標準的なエラーログよりもはるかに多くの情報が得られます。特に複雑なエラー階層を扱う場合に有効です。

ErrorKitの組み込みサポート

ErrorKitの組み込みエラー型FileErrorNetworkError など)はすべて Catching に準拠しているため、すぐに使えます:

func saveUserData() throws(DatabaseError) {
    // Automatically wraps SQLite errors, file system errors, etc.
    try DatabaseError.catch {
        try database.beginTransaction()
        try database.execute(query)
        try database.commit()
    }
}

実践例:典型的なアプリケーション

より完全な例で、これがどのように機能するか見てみましょう:

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

    // This could throw file system errors
    try DatabaseError.catch {
        let query = try QueryBuilder.build(for: id)
        return try database.execute(query)
    }
}

// Business Logic Layer
func processUserProfile(id: String) throws(ProfileError) {
    guard isValidID(id) else {
        throw ProfileError.invalidInput
    }

    // This automatically wraps DatabaseError
    let userData = try ProfileError.catch {
        return try fetchUserData(id: id)
    }

    // Process the user data...
}

// Presentation Layer
func displayUserProfile(id: String) throws(UIError) {
    // This automatically wraps ProfileError (which might contain DatabaseError)
    let profile = try UIError.catch {
        return try processUserProfile(id: id)
    }

    // Display the profile...
}

データベース接続が失敗した場合、エラーチェーンではこのように表示されます:

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

何が起きたか、エラーの発生元はどこか、すべてが正確にわかります。デバッグがずっと楽になりますし、追加されたコンテキストが問題の解決に役立つヒントを与えてくれるでしょう!

まとめ

Swift 6のTyped Throwsは言語への強力な追加機能ですが、レイヤー間のエラー伝播に課題をもたらします。Catching プロトコルは、型安全性を維持しながらボイラープレートを排除する、シンプルでエレガントなソリューションを提供します。

ErrorKitの errorChainDescription 関数と組み合わせることで、エラー処理は強力なデバッグツールになります。ErrorKitを使って、実際のアプリでSwiftのエラー処理をより実用的にする多くの改善を活用しましょう:

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

Swift 6のTyped Throwsを使い始めましたか?アプリのレイヤー間でのエラー伝播はどのように処理していますか?ぜひソーシャルメディア(リンクは下記)で教えてください!

このシリーズの前の記事:

  1. Swiftのエラー処理を正しく行う:Objective-Cの負の遺産を乗り越える

このシリーズの続きの記事:

  1. Swiftアプリのエラーレポートを改善する:自動ログ+アナリティクス

  2. Swiftのエラーメッセージをみんなでユーザーフレンドリーにしよう

この記事が参考になりましたか?BlueskyMastodonでフォローして、Swiftのヒントやインディー開発の最新情報をチェックしてください。