Propagating user-facing errors in Swift
Basics article available: Error HandlingIf 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! 🚀