Generic error handling in SwiftUI

by @ralfebert · published July 29, 2021
Xcode 13 & iOS 15
Advanced iOS Developers

In SwiftUI apps, often different Views want to handle errors in the same fashion. So let's create an abstraction to define how an error should be presented to the user. In this example, an Alert will be used to show the errors – actual apps might come up with a more sophisticated UI.

Representing errors

I recommend using the ↗ Error protocol to represent all error situations in Swift code. This has the advantage that you can use the same mechanism for your errors and for errors from iOS or from other libraries. Also, errors can be ↗ thrown and caught, and they come with a built-in mechanism for providing a localizable explanation to the user.

To represent error situations define an enum conforming to the Error protocol and add a case for every error situation that can happen:

enum ValidationError: LocalizedError {
    case missingName

    var errorDescription: String? {
        switch self {
        case .missingName:
            return "Name is a required field."
        }
    }
}

In an error situation throw the error:

struct Person {
    var name: String = ""

    func validate() throws {
        if name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
            throw ValidationError.missingName
        }
    }
}

Usage example

To handle an error in a view, let's use an object in the environment that's responsible for handling errors and delegate the error handling to this object:

struct ContentView: View {
    @EnvironmentObject var errorHandling: ErrorHandling
    @State var person = Person()

    var body: some View {
        Form {
            TextField("Name", text: $person.name)

            Button("Submit") {
                do {
    try person.validate()
} catch {
    self.errorHandling.handle(error: error)
}
            }
        }
    }
}

Often errors come from interactive actions - you could even define a TryButton that simplifies this by allowing to throw errors in its action block:

TryButton("Submit") {
    try self.validate()
}

Let's also define a view modifier that provides the errorHandling @EnvironmentObject and handles the errors. It might be worthwhile to add this at the top level in the App struct:

@main
struct ExampleApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .withErrorHandling()
        }
    }
}

Implementation

As a starting point, here is an example implementation of the ErrorHandling class:

struct ErrorAlert: Identifiable {
    var id = UUID()
    var message: String
    var dismissAction: (() -> Void)?
}

class ErrorHandling: ObservableObject {
    @Published var currentAlert: ErrorAlert?

    func handle(error: Error) {
        currentAlert = ErrorAlert(message: error.localizedDescription)
    }
}

The .withErrorHandling modifier:

struct HandleErrorsByShowingAlertViewModifier: ViewModifier {
    @StateObject var errorHandling = ErrorHandling()

    func body(content: Content) -> some View {
        content
            .environmentObject(errorHandling)
            // Applying the alert for error handling using a background element
            // is a workaround, if the alert would be applied directly,
            // other .alert modifiers inside of content would not work anymore
            .background(
                EmptyView()
                    .alert(item: $errorHandling.currentAlert) { currentAlert in
                        Alert(
                            title: Text("Error"),
                            message: Text(currentAlert.message),
                            dismissButton: .default(Text("Ok")) {
                                currentAlert.dismissAction?()
                            }
                        )
                    }
            )
    }
}

extension View {
    func withErrorHandling() -> some View {
        modifier(HandleErrorsByShowingAlertViewModifier())
    }
}

Have look at the example project for the ↗ TryButton.