Articles, podcasts and news about Swift development, by John Sundell.

Propagating user-facing errors in Swift

Published on 10 May 2020
Basics article available: Error Handling

If it’s one thing that almost all programs have in common is that they will, at some point, encounter some form of error. While some errors might be the result of bugs and failures caused by faulty code, incorrect assumptions, or system incompatibilities — there are also multiple kinds of errors that are completely normal, valid parts of a program’s execution.

One challenge with such errors is how to propagate and present them to the user, which can be really tricky, even if we disregard tasks like crafting informative and actionable error messages. It’s so incredibly common to see apps either display a generic ”An error occurred” message regardless of what kind of error that was encountered, or throw walls of highly technical debugging text at the user — neither of which is a great user experience.

So this week, let’s take a look at a few techniques that can make it much simpler to propagate runtime errors to our users, and how employing some of those techniques could help us present richer error messages without having to add a ton of complexity within each UI implementation.

An evolution from simple to complex

When starting to build a new app feature, it’s arguably a good idea to start out as simple as possible — which typically helps us avoid premature optimization, by enabling us to discover the most appropriate structure and abstractions as we iterate on our code.

When it comes to error propagation, such a simple implementation might look like the following example — in which we attempt to load a list of conversations within some form of messaging app, and then pass any error that was encountered into a private handle method:

class ConversationListViewController: UIViewController {
    private let loader: ConversationLoader
    
    ...

    private func loadConversations() {
        // Load our list of converstions, and then either render
        // our results, or handle any error that was encountered:
        loader.loadConversations { [weak self] result in
            switch result {
            case .success(let conversations):
                self?.render(conversations)
            case .failure(let error):
                self?.handle(error)
            }
        }
    }
}

Our handle method might then, for example, create a UIAlertController in order to render the passed error’s localizedDescription to the user, along with a ”Retry” button — like this:

private extension ConversationListViewController {
    func handle(_ error: Error) {
        let alert = UIAlertController(
            title: "An error occured",
            message: error.localizedDescription,
            preferredStyle: .alert
        )
        
        alert.addAction(UIAlertAction(
            title: "Dismiss",
            style: .default
        ))

        alert.addAction(UIAlertAction(
            title: "Retry",
            style: .default,
            handler: { [weak self] _ in
                self?.loadConversations()
            }
        ))

        present(alert, animated: true)
    }
}

While the above approach (or something like it, such as using a custom error child view controller, rather than an alert view) is incredibly common, it does come with a few significant drawbacks.

First of all, since we’re directly rendering any error that was encountered while loading our list of models, chances are high that we’ll end up displaying code-level implementation details to the user — which isn’t great — and secondly, we’re always showing a “Retry” button, regardless of whether retrying the operation will realistically yield a different result.

To address those two issues, we might try to make our errors a bit more granular and well-defined, for example by introducing a dedicated NetworkingError enum that we make sure has proper localized messages for each case:

enum NetworkingError: LocalizedError {
    case deviceIsOffline
    case unauthorized
    case resourceNotFound
    case serverError(Error)
    case missingData
    case decodingFailed(Error)
}

If we then go back and retrofit our ConversationLoader with support for our new error enum, we’ll end up with a much more unified error API that our various UI components will be able to use to handle errors in a more precise manner:

class ConversationLoader {
    typealias Handler = (Result<[Conversation], NetworkingError>) -> Void
    
    ...

    func loadConverstions(then handler: @escaping Handler) {
        ...
    }
}

However, performing error handling in a ”precise manner” is easier said than done, and often leads to a ton of complicated code that needs to be specifically written for each feature or use case — since each part of our code base is likely to use a slightly different set of errors.

As an example, here’s how complex our previous handle method now has become, once we’ve started customizing the way we present our errors depending on what type of error that was encountered:

private extension ConversationListViewController {
    func handle(_ error: NetworkingError) {
        let alert = UIAlertController(
            title: "An error occured",
            message: error.localizedDescription,
            preferredStyle: .alert
        )
        
        alert.addAction(UIAlertAction(
            title: "Dismiss",
            style: .default
        ))

        // Here we take different actions depending on the error
        // that was encontered. We've decided that only some
        // errors warrant a "Retry" button, while an "unauthorized"
        // error should redirect the user to the login screen,
        // since their login session has most likely expired:
        switch error {
        case .deviceIsOffline, .serverError:
            alert.addAction(UIAlertAction(
                title: "Retry",
                style: .default,
                handler: { [weak self] _ in
                    self?.loadConversations()
                }
            ))
        case .resourceNotFound, .missingData, .decodingFailed
            break
        case .unauthorized:
            return navigator.logOut()
        }

        present(alert, animated: true)
    }
}

While the above will most likely lead to an improved user experience — since we’re now tailoring the presentation of each error according to what the user can reasonably do about it — maintaining that sort of complexity within each feature isn’t going to be fun, so let’s see if we can find a better solution.

Using the power of the responder chain

If we remain within the realm of UIKit for now, one way to improve the way that UI-related errors are propagated within an app is to make use of the responder chain.

The responder chain is a system that both UIKit and AppKit have in common (although its implementation does differ between the two frameworks), and is how all sorts of system events — from touches, to keyboard events, to input focus — are handled. Any UIResponder subclass (such as UIView and UIViewController) can participate in the responder chain, and the system will automatically add all of our views and view controllers to it as soon as they’re added to our view hierarchy.

On iOS, the responder chain starts at the app’s AppDelegate, and goes all the way through our view hierarchy until it reaches our topmost views — which means that it is, in many ways, an ideal tool to use for tasks like propagation.

So let’s take a look at how we could move our error handling and propagation code to the responder chain — by first extending UIResponder with a method that, by default, moves any error that we send to it upwards through the chain using the built-in next property:

extension UIResponder {
    // We're dispatching our new method through the Objective-C
    // runtime, to enable us to override it within subclasses:
    @objc func handle(_ error: Error,
                      from viewController: UIViewController,
                      retryHandler: @escaping () -> Void) {
        // This assertion will help us identify errors that were
        // either emitted by a view controller *before* it was
        // added to the responder chain, or never handled at all:
        guard let nextResponder = next else {
            return assertionFailure("""
            Unhandled error \(error) from \(viewController)
            """)
        }

        nextResponder.handle(error,
            from: viewController,
            retryHandler: retryHandler
        )
    }
}

The above design is quite similar to AppKit’s presentError API, which also uses the responder chain in a similar fashion.

Since much of our UI-based error propagation is likely to originate from view controllers, let’s also extend UIViewController with the following convenience API to avoid having to manually pass self every time that we want to handle an error:

extension UIViewController {
    func handle(_ error: Error,
                retryHandler: @escaping () -> Void) {
        handle(error, from: self, retryHandler: retryHandler)
    }
}

Using our new API is now almost as simple as calling the private handle method that we previously used within our ConversationListViewController:

class ConversationListViewController: UIViewController {
    ...

    func loadConversations() {
        loader.loadConversations { [weak self] result in
            switch result {
            case .success(let conversations):
                self?.render(conversations)
            case .failure(let error):
                self?.handle(error, retryHandler: {
                    self?.loadConversations()
                })
            }
        }
    }
}

With our new error propagation system in place, we can now implement our error handling code anywhere within the responder chain — which both gives us a ton of flexibility, and also lets us move away from requiring each view controller to manually implement its own error handling code.

Generic error categories

However, before we’ll be able to fully utilize our new error handling system, we’re going to need a slightly more generic way to identify the various errors that our code can produce — otherwise we’ll likely end up with quite massive implementations that need to perform lots of type casting between our different error types.

One way to make that happen would be to introduce a set of error categories that we can divide our app’s errors into — for example by using an enum and a specialized CategorizedError protocol:

enum ErrorCategory {
    case nonRetryable
    case retryable
    case requiresLogout
}

protocol CategorizedError: Error {
    var category: ErrorCategory { get }
}

Now all that we have to do to categorize an error is make it conform to the above protocol, like this:

extension NetworkingError: CategorizedError {
    var category: ErrorCategory {
        switch self {
        case .deviceIsOffline, .serverError:
            return .retryable
        case .resourceNotFound, .missingData, .decodingFailed:
            return .nonRetryable
        case .unauthorized:
            return .requiresLogout
        }
    }
}

Finally, let’s also extend Error with a convenience API that’ll let us retrieve an ErrorCategory from any error — by falling back to a default category for errors that don’t yet support categorization:

extension Error {
    func resolveCategory() -> ErrorCategory {
        guard let categorized = self as? CategorizedError else {
            // We could optionally choose to trigger an assertion
            // here, if we consider it important that all of our
            // errors have categories assigned to them.
            return .nonRetryable
        }

        return categorized.category
    }
}

With the above in place, we’ll now be able to write our error handling code in a complete reusable way, without losing any precision. In this case, we’ll do that by extending our AppDelegate (which sits at the top of the responder chain) with the following implementation:

extension AppDelegate {
    override func handle(_ error: Error,
                         from viewController: UIViewController,
                         retryHandler: @escaping () -> Void) {
        let alert = UIAlertController(
            title: "An error occured",
            message: error.localizedDescription,
            preferredStyle: .alert
        )

        alert.addAction(UIAlertAction(
            title: "Dismiss",
            style: .default
        ))

        switch error.resolveCategory() {
        case .retryable:
            alert.addAction(UIAlertAction(
                title: "Retry",
                style: .default,
                handler: { _ in retryHandler() }
            ))
        case .nonRetryable:
            break
        case .requiresLogout:
            return performLogout()
        }

        viewController.present(alert, animated: true)
    }
}

Apart from the fact that we now have a single error handling implementation that can be used to present any error that was encountered by any of our view controllers, the power of the responder chain is that we can also easily insert more specific handling code anywhere within that chain.

For example, if an error that requires logout (such as an authorization error) was encountered on our login screen, we probably want to display an error message, rather than attempting to log the user out. To make that happen, we just have to implement handle within that view controller, add our custom error handling, and then pass any errors that we don’t wish to handle at that level to our superclass — like this:

extension LoginViewController {
    override func handle(_ error: Error,
                         from viewController: UIViewController,
                         retryHandler: @escaping () -> Void) {
        guard error.resolveCategory() == .requiresLogout else {
            return super.handle(error,
                from: viewController,
                retryHandler: retryHandler
            )
        }

        errorLabel.text = """
        Login failed. Check your username and password.
        """
    }
}

The above override will also catch all errors produced by our login view controller’s children.

While there are a number of other factors that we might want to take into account when handling errors (such as avoiding stacking multiple alerts on top of each other, or to automatically retry certain operations rather than showing an error), using the responder chain to propagate user-facing errors can be incredibly powerful — as it lets us write finely grained error handling code without having to spread that code across all of our various UI implementations.

From UIKit to SwiftUI

Next, let’s take a look at how we could achieve a setup similar to the UIKit-based one that we just explored, but within SwiftUI instead. While SwiftUI does not have an actual responder chain, it does feature other mechanisms that let us propagate information upwards and downwards through a view hierarchy.

To get started, let’s create an ErrorHandler protocol that we’ll use to define our various error handlers. When asked to handle an error, we’ll also give each handler access to the View that the error was encountered in, as well as a LoginStateController that’s used to manage our app’s login state, and just like within our UIKit-based implementation, we’ll use a retryHandler closure to enable failed operations to be retried:

protocol ErrorHandler {
    func handle<T: View>(
        _ error: Error?,
        in view: T,
        loginStateController: LoginStateController,
        retryHandler: @escaping () -> Void
    ) -> AnyView
}

Note that the above error parameter is an optional, which will later enable us to pass in our view errors in a declarative, SwiftUI-friendly way.

Next, let’s write a default implementation of the above protocol, which (just like when using UIKit) will present an alert view for each error that was encountered. It’ll do so by converting its passed parameters into an internal Presentation model, which will then be wrapped in a Binding value and used to present an Alert — like this:

struct AlertErrorHandler: ErrorHandler {
    // We give our handler an ID, so that SwiftUI will be able
    // to keep track of the alerts that it creates as it updates
    // our various views:
    private let id = UUID()

    func handle<T: View>(
        _ error: Error?,
        in view: T,
        loginStateController: LoginStateController,
        retryHandler: @escaping () -> Void
    ) -> AnyView {
        guard error?.resolveCategory() != .requiresLogout else {
            loginStateController.state = .loggedOut
            return AnyView(view)
        }

        var presentation = error.map { Presentation(
            id: id,
            error: $0,
            retryHandler: retryHandler
        )}

        // We need to convert our model to a Binding value in
        // order to be able to present an alert using it:
        let binding = Binding(
            get: { presentation },
            set: { presentation = $0 }
        )

        return AnyView(view.alert(item: binding, content: makeAlert))
    }
}

The reason we need a Presentation model is because SwiftUI requires a value to be Identifiable in order to be able to display an alert for it. By using our handler’s own UUID as our identifier (like we do above), we’ll be able to provide SwiftUI with a stable identity for each alert that we create, even as it updates and re-renders our views.

Let’s now implement that Presentation model, along with the private makeAlert method that we call above, and our default ErrorHandler implementation will be complete:

private extension AlertErrorHandler {
    struct Presentation: Identifiable {
        let id: UUID
        let error: Error
        let retryHandler: () -> Void
    }
    
    func makeAlert(for presentation: Presentation) -> Alert {
        let error = presentation.error

        switch error.resolveCategory() {
        case .retryable:
            return Alert(
                title: Text("An error occured"),
                message: Text(error.localizedDescription),
                primaryButton: .default(Text("Dismiss")),
                secondaryButton: .default(Text("Retry"),
                    action: presentation.retryHandler
                )
            )
        case .nonRetryable:
            return Alert(
                title: Text("An error occured"),
                message: Text(error.localizedDescription),
                dismissButton: .default(Text("Dismiss"))
            )
        case .requiresLogout:
            // We don't expect this code path to be hit, since
            // we're guarding for this case above, so we'll
            // trigger an assertion failure here.
            assertionFailure("Should have logged out")
            return Alert(title: Text("Logging out..."))
        }
    }
}

The next thing that we’ll need is a way to pass the current error handler downwards through our view hierarchy, which interestingly is the opposite direction compared to how we implemented things using the UIKit responder chain. While SwiftUI does feature APIs for upwards propagation (such as the preferences system that we used to implement syncing between views in part two of “A guide to the SwiftUI layout system”), passing objects and information downwards is often a much better fit for SwiftUI’s highly declarative nature.

To make that happen, let’s use SwiftUI’s environment system, which enables us to add key objects and values to our view hierarchy’s overall environment — which any view or modifier will then be able to obtain.

Doing so involves two steps in this case. First, we’ll define an EnvironmentKey for storing our current error handler, and we’ll then extend the EnvironmentValues type with a computed property for accessing it — like this:

struct ErrorHandlerEnvironmentKey: EnvironmentKey {
    static var defaultValue: ErrorHandler = AlertErrorHandler()
}

extension EnvironmentValues {
    var errorHandler: ErrorHandler {
        get { self[ErrorHandlerEnvironmentKey.self] }
        set { self[ErrorHandlerEnvironmentKey.self] = newValue }
    }
}

Since we’ve made an instance of AlertErrorHandler our default environment value, we don’t need to explicitly inject an error handler when constructing our views — except when we’ll want to override the default handler for a subset of our hierarchy (like we did for our login screen when using UIKit). To make such overrides simpler to add, let’s create a convenience API for it:

extension View {
    func handlingErrors(
        using handler: ErrorHandler
    ) -> some View {
        environment(\.errorHandler, handler)
    }
}

With the above in place, we now have everything that’s needed for handling errors, so now let’s implement the other side of the coin — emitting them.

To enable any view to easily emit the user-facing errors that it encounters, let’s use SwiftUI’s view modifier system to encapsulate all of the logic required to connect an error and a retry handler to the error handling system that we built above:

struct ErrorEmittingViewModifier: ViewModifier {
    @EnvironmentObject var loginStateController: LoginStateController
    @Environment(\.errorHandler) var handler

    var error: Error?
    var retryHandler: () -> Void

    func body(content: Content) -> some View {
        handler.handle(error,
            in: content,
            loginStateController: loginStateController,
            retryHandler: retryHandler
        )
    }
}

Note how we use two different property wrappers for accessing our above environment objects. The @Environment wrapper enables us to read values directly from the environment itself, while the @EnvironmentObject one enables us to obtain an object that was passed down from a parent view.

While we could simply use our new view modifier directly within our views, let’s also create a convenience API for it, for example like this:

extension View {
    func emittingError(
        _ error: Error?,
        retryHandler: @escaping () -> Void
    ) -> some View {
        modifier(ErrorEmittingViewModifier(
            error: error,
            retryHandler: retryHandler
        ))
    }
}

With the above in place, our SwiftUI-based error propagation system is now finished — so let’s take it for a spin! Even though the system itself was quite complex to build, the resulting call sites can remain very simple — since all that a view needs to do to propagate an error is to call the emittingError API that we just defined, and our new error propagation system will take care of the rest.

Here’s what that might look like in a rewritten SwiftUI-version of our ConversationListViewController from before (which now also has an accompanying view model):

class ConversationListViewModel: ObservableObject {
    @Published private(set) var error: Error?
    @Published private(set) var conversations: [Conversation]
    ...
}

struct ConversationListView: View {
    @ObservedObject var viewModel: ConversationListViewModel

    var body: some View {
        List(viewModel.conversations, rowContent: makeRow)
            .emittingError(viewModel.error, retryHandler: {
                self.viewModel.load()
            })
            .onAppear(perform: viewModel.load)
            ...
    }

    private func makeRow(for conversation: Conversation) -> some View {
        ...
    }
}

The final piece of the puzzle is that when we’re setting up our view hierarchy, we need to make sure to inject our LoginStateController into our environment (to enable it to later be retrieved by our ErrorEmittingViewModifier), which can be done like this:

RootView(...).environmentObject(loginStateController)

We’ll take a much closer look at SwiftUI’s various environment APIs, and how they can be used for dependency injection, in future articles.

In many ways, the two implementations of our error propagation system really show just how different UIKit and SwiftUI are — as SwiftUI required us to add several new types, but also enabled us construct a fully declarative API that’s inline with the built-in APIs that SwiftUI itself ships with.

Conclusion

When dealing with user-facing errors, such as those encountered within our UI code, it’s typically a good idea to come up with some form of system or architecture that lets us propagate those kinds of errors to a central handling mechanism.

When using UIKit or AppKit, that could be done using the responder chain, while SwiftUI-based apps might opt to use either the environment or preferences system, or by going for some kind of unidirectional approach for both emitting errors and other events.

Either way, let’s make those simple “An error occurred” dialogs a thing of the past, shall we? 🙂

Got questions, comments, or feedback? Feel free to reach out either via Twitter or email.

Thanks for reading! 🚀