Post

Modularized SwiftUI: Error Handling & Alerts

This is a second article in a short series about architecture concepts for apps with multiple small independent modules. You can find the previous article here: Decoupled stacked sheet navigation with multiple modals in SwiftUI.

In this article, we focus on the consistent handling of errors across independent modules. And thereby enhancing the user experience and simplifying the development process.

Defining custom errors

To start, we define our own errors with localized user-facing messages. This is achieved by conforming our error to the LocalizedError protocol.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
enum APIError: LocalizedError {
    case networkUnavailable

    public var errorDescription: String? {
        switch self {
        case .networkUnavailable:
            return "Network Unavailable"
        }
    }

    public var failureReason: String? {
        switch self {
        case .networkUnavailable:
            return "It seems that your device is not connected to the internet."
        }
    }

    public var recoverySuggestion: String? {
        switch self {
        case .networkUnavailable:
            return "Please confirm your network connection and try again."
        }
    }
}

A typically feature module will have logic that throws an error at some point. For example when trying to load data while the network connection is not available.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct SomeView: View, Routable {
    var body: some View {
        Text("Hello, world!")
        .task {
            do {
                try await load()
            } catch {
              // handle the error
            }
        }
    }

    private func load() async throws {
      // ...
      throw APIError.networkUnavailable
    }
}

Again, as in the previous article we have a Router that handles navigations and transitions.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import Observation

@MainActor
@Observable
public class Router {
    public init() {}
    public var presentedError: LocalizedError?

    public var showingAlert: Bool {
        get {
            presentedError != nil
        }
        set {
            if !newValue {
                presentedError = nil
            }
        }
    }

    public func showAlert(for error: LocalizedError) {
        presentedError = error
    }
}

At the root of our app, we inject the Router observable into the apps environment and wire up the alert presentation logic in a single point.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import Navigation

public struct MyApp: App {
    @Bindable private var router = Router()

    public var body: some Scene {
        WindowGroup {
            MainView()
                .alert(
                    router.presentedError?.errorDescription ?? "Error",
                    isPresented: $router.showingAlert,
                    presenting: router.presentedError,
                    actions: { _ in },
                    message: { error in
                        if error.failureReason != nil || error.recoverySuggestion != nil {
                            Text(error.failureReason ?? "")
                            + Text("\n")
                            + Text(error.recoverySuggestion ?? "")
                        } else {
                          // Gracefully handle unknown errors 
                        }
                    }
                )
        }
        .environment(router)
    }
}

This would require some redundant error handling code in our module view.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import Navigation

struct SomeView: View, Routable {
    @Environment(Router.self) private var router

    var body: some View {
        Text("Hello, world!")
        .task {
            do {
                try await load()
            } catch {
                handle(error)
            }
        }
    }

    // ...

    func handle(_ error: Error) {
        if let error = error as? LocalizedError {
            router.showAlert(for: error)
        } else {
            // handle other errors (e.g. with logging only)
        }
    }
}

But if we extract that part into a view extension of our Navigation module, it can be shared with all feature modules:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public protocol Routable: Observable {
    var router: Router { get }
    func handle(_ error: Error)
}

@MainActor
public extension Routable {
    func handle(_ error: Error) {
        if let error = error as? LocalizedError {
            router.showAlert(for: error)
        } else {
            // handle other errors (e.g. with logging only)
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import Navigation

struct SomeView: View, Routable {
    @Environment(Router.self) private var router

    var body: some View {
        Text("Hello, world!")
        .task {
            do {
                try await load()
            } catch {
                handle(error)
            }
        }
    }
}

Sharing Errors Across Modules

Now, this is all well and good for cases where our errors are only used in the modules they are defined in.
But what if we have an error that is better reused across multiple modules.
We can move our APIError type from a specific module to a separate shared module on which these feature modules depend.

graph LR
 App --> FeatureA
 App --> FeatureB
 App --> FeatureC
 FeatureA --> Navigation
 FeatureB --> Navigation
 FeatureC --> Navigation
FeatureA --> SharedError
FeatureB --> SharedError

What if we want to handle some errors differently than others? We can move the specific errors one step deeper into our dependency graph as a dependency of Navigation. Which allows handle them as needed without the feature models needing to know about our navigation logic.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import SharedError

// ...

func handle(_ error: Error) {
    switch error {
    case let apiError as APIError:
        router.showAlert(for: apiError)

    case let localizedError as LocalizedError:
        // Bonus: with the pragma below, we show unspecified alerts only for debugging and testing
        #if DEV  
            router.showAlert(for: localizedError)
        #else
            break
        #endif

    default:
        break
    }
    Logger.error(localizedError)
}

Thanks for reading this far! 😆
The above pattern matching may seem a bit unusual, but fits our case quite well. If you’re unfamiliar with it, I recommend visiting the AppVenture Blog for a collection of advanced pattern matching expressions.

The dependency graph for this would look as follows:

graph LR
 App --> FeatureA
 App --> FeatureB
 App --> FeatureC
 FeatureA --> Navigation
 FeatureB --> Navigation
 FeatureC --> Navigation
Navigation --> SharedError

Conclusion

By adopting a consistent and modular approach to error handling, we can significantly improve the user experience of our apps. This strategy not only simplifies development but also enhances the app’s robustness and maintainability. As we continue to explore the architecture of apps with multiple small, independent modules, we’ll discover more ways to leverage SwiftUI and Swift’s powerful features to build sophisticated and user-friendly applications.

Stay tuned for more insights into SwiftUI and app architecture in our upcoming articles.

This post is licensed under CC BY 4.0 by the author.