Generic error handling in SwiftUI

by @ralfebert · published July 29, 2021

In a SwiftUI app many different views often want to handle errors in the same way. So let's create an abstraction to define how an error should be displayed to the user. In this example, I'll use an Alert to show the error (actual apps might come up with a more sophisticated UI):

Representing errors

First of all, I recommend using the ↗ Error protocol to represent all error situations in Swift. This comes with the advantage that you then can use the same mechanism for your own errors and for showing errors from iOS or from other libraries. Also errors can be ↗ thrown and catched and they come with a built-in mechanism for providing an explanation to the user that can be localized.

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 "Something is wrong here."
        }
    }
}

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 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.