LocalizedError, RecoverableError, CustomNSError
Swift 2 introduced error handling by way of the
throws, do, try and catch keywords.
It was designed to work hand-in-hand with
Cocoa error handling conventions,
such that any type conforming to the Error protocol
(since renamed to Error)
was implicitly bridged to NSError and
Objective-C methods with an NSError** parameter,
were imported by Swift as throwing methods.
- (NSURL *)replaceItemAtURL:(NSURL *)url
options:(NSFileVersionReplacingOptions)options
error:(NSError * _Nullable *)error;
func replaceItem(at url: URL,
options: NSFileVersion.ReplacingOptions = []) throws -> URL
For the most part, these changes offered a dramatic improvement over the status quo (namely, no error handling conventions in Swift at all). However, there were still a few gaps to fill to make Swift errors fully interoperable with Objective-C types. as described by Swift Evolution proposal SE-0112: “Improved NSError Bridging”.
Not long after these refinements landed in Swift 3, the practice of declaring errors in enumerations had become idiomatic.
Yet for how familiar we’ve all become with
Error (née Error),
surprisingly few of us are on a first-name basis with
the other error protocols to come out of SE-0112.
Like, when was the last time you came across Localized in the wild?
How about Recoverable?
Custom qu’est-ce que c’est?
At the risk of sounding cliché, you might say that these protocols are indeed pretty obscure, and there’s a good chance you haven’t heard of them:
LocalizedError -
A specialized error that provides localized messages describing the error and why it occurred.
RecoverableError -
A specialized error that may be recoverable by presenting several potential recovery options to the user.
CustomNSError -
A specialized error that provides a domain, error code, and user-info dictionary.
If you haven’t heard of any of these until now, you may be wondering when you’d ever use them. Well, as the adage goes, “There’s no time like the present”.
This week on NSHipster, we’ll take a quick look at each of these Swift Foundation error protocols and demonstrate how they can make your code — if not less error-prone — then more enjoyable in its folly.
Communicating Errors to the User
Too many cooks spoil the broth.
Consider the following Broth type
with a nested Error enumeration
and an initializer that takes a number of cooks
and throws an error if that number is inadvisably large:
struct Broth {
enum Error {
case tooManyCooks(Int)
}
init(numberOfCooks: Int) throws {
precondition(numberOfCooks > 0)
guard numberOfCooks < redacted else {
throw Error.tooManyCooks(numberOfCooks)
}
// ... proceed to make broth
}
}
If an iOS app were to communicate an error
resulting from broth spoiled by multitudinous cooks,
it might do so
with by presenting a UIAlert
in a catch statement like this:
import UIKit
class ViewController: UIViewController {
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
do {
self.broth = try Broth(numberOfCooks: 100)
} catch let error as Broth.Error {
let title: String
let message: String
switch error {
case .tooManyCooks(let numberOfCooks):
title = "Too Many Cooks (\(numberOfCooks))"
message = """
It's difficult to reconcile many opinions.
Reduce the number of decision makers.
"""
}
let alertController =
UIAlertController(title: title,
message: message,
preferredStyle: .alert)
alertController.addAction(
UIAlertAction(title: "OK",
style: .default)
)
self.present(alertController, animated: true, completion: nil)
} catch {
// handle other errors...
}
}
}
Such an implementation, however, is at odds with well-understood boundaries between models and controllers. Not only does it create bloat in the controller, it also doesn’t scale to handling multiple errors or handling errors in multiple contexts.
To reconcile these anti-patterns, let’s turn to our first Swift Foundation error protocol.
Adopting the LocalizedError Protocol
The Localized protocol inherits the base Error protocol
and adds four instance property requirements.
protocol LocalizedError : Error {
var errorDescription: String? { get }
var failureReason: String? { get }
var recoverySuggestion: String? { get }
var helpAnchor: String? { get }
}
These properties map 1:1 with familiar NSError user keys.
| Requirement | User Info Key |
|---|---|
error | NSLocalized |
failure | NSLocalized |
recovery | NSLocalized |
help | NSHelp |
Let’s take another pass at our nested Broth.Error type
and see how we might refactor error communication from the controller
to instead be concerns of Localized conformance.
import Foundation
extension Broth.Error: LocalizedError {
var errorDescription: String? {
switch self {
case .tooManyCooks(let numberOfCooks):
return "Too Many Cooks (\(numberOfCooks))"
}
}
var failureReason: String? {
switch self {
case .tooManyCooks:
return "It's difficult to reconcile many opinions."
}
}
var recoverySuggestion: String? {
switch self {
case .tooManyCooks:
return "Reduce the number of decision makers."
}
}
}
Using switch statements may be overkill
for a single-case enumeration such as this,
but it demonstrates a pattern that can be extended
for more complex error types.
Note also how pattern matching is used
to bind the number constant to the associated value
only when it’s necessary.
Now we can
import UIKit
class ViewController: UIViewController {
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
do {
try makeBroth(numberOfCooks: 100)
} catch let error as LocalizedError {
let title = error.errorDescription
let message = [
error.failureReason,
error.recoverySuggestion
].compactMap { $0 }
.joined(separator: "\n\n")
let alertController =
UIAlertController(title: title,
message: message,
preferredStyle: .alert)
alertController.addAction(
UIAlertAction(title: "OK",
style: .default)
)
self.present(alertController, animated: true, completion: nil)
} catch {
// handle other errors...
}
}
}
If that seems like a lot of work just to communicate an error to the user… you might be onto something.
Although UIKit borrowed many great conventions and idioms from AppKit, error handling wasn’t one of them. By taking a closer look at what was lost in translation, we’ll finally have the necessary context to understand the two remaining error protocols to be discussed.
Communicating Errors on macOS
If at first you don’t succeed, try, try again.
Communicating errors to users is significantly easier on macOS than on iOS.
For example,
you might construct and pass an NSError object
to the present method,
called on an NSWindow.
import AppKit
@NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate {
@IBOutlet weak var window: NSWindow!
func applicationDidFinishLaunching(_ aNotification: Notification) {
do {
_ = try something()
} catch {
window.presentError(error)
}
}
func something() throws -> Never {
let userInfo: [String: Any] = [
NSLocalizedDescriptionKey:
NSLocalizedString("The operation couldn’t be completed.",
comment: "localizedErrorDescription"),
NSLocalizedRecoverySuggestionErrorKey:
NSLocalizedString("If at first you don't succeed...",
comment: "localizedErrorRecoverSuggestion")
]
throw NSError(domain: "com.nshipster.error", code: 1, userInfo: userInfo)
}
}
Doing so presents a modal alert dialog that fits right in with the rest of the system.
But macOS error handling isn’t merely a matter of convenient APIs; it also has built-in mechanisms for allowing users to select one of several options to attempt to resolve the reported issue.
Recovering from Errors
To turn a conventional NSError into
one that supports recovery,
you specify values for the user keys
NSLocalized and NSRecovery.
A great way to do that
is to override the application(_:will delegate method
and intercept and modify an error before it’s presented to the user.
extension AppDelegate {
func application(_ application: NSApplication,
willPresentError error: Error) -> Error
{
var userInfo: [String: Any] = (error as NSError).userInfo
userInfo[NSLocalizedRecoveryOptionsErrorKey] = [
NSLocalizedString("Try, try again",
comment: "tryAgain")
NSLocalizedString("Give up too easily",
comment: "giveUp")
]
userInfo[NSRecoveryAttempterErrorKey] = self
return NSError(domain: (error as NSError).domain,
code: (error as NSError).code,
userInfo: userInfo)
}
}
For NSLocalized,
specify an array of one or more localized strings
for each recovery option available the user.
For NSRecovery,
set an object that implements the
attempt method.
extension AppDelegate {
// MARK: NSErrorRecoveryAttempting
override func attemptRecovery(fromError error: Error,
optionIndex recoveryOptionIndex: Int) -> Bool
{
do {
switch recoveryOptionIndex {
case 0: // Try, try again
try something()
case 1:
fallthrough
default:
break
}
} catch {
window.presentError(error)
}
return true
}
}
With just a few lines of code, you’re able to facilitate a remarkably complex interaction, whereby a user is alerted to an error and prompted to resolve it according to a set of available options.
Cool as that is,
it carries some pretty gross baggage.
First,
the attempt requirement is part of an
informal protocol,
which is effectively a handshake agreement
that things will work as advertised.
Second,
the use of option indexes instead of actual objects
makes for code that’s as fragile as it is cumbersome to write.
Fortunately, we can significantly improve on this by taking advantage of Swift’s superior type system and (at long last) the second subject of this article.
Modernizing Error Recovery with RecoverableError
The Recoverable protocol,
like Localized is a refinement on the base Error protocol
with the following requirements:
protocol RecoverableError : Error {
var recoveryOptions: [String] { get }
func attemptRecovery(optionIndex recoveryOptionIndex: Int, resultHandler handler: @escaping (Bool) -> Void)
func attemptRecovery(optionIndex recoveryOptionIndex: Int) -> Bool
}
Also like Localized,
these requirements map onto error user keys
(albeit not as directly).
| Requirement | User Info Key |
|---|---|
recovery | NSLocalized |
attempt attempt | NSRecovery * |
The recovery property requirement
is equivalent to the NSLocalized:
an array of strings that describe the available options.
The attempt functions
formalize the previously informal delegate protocol;
func attempt
is for “application” granularity,
whereas
attempt
is for “document” granularity.
Supplementing RecoverableError with Additional Types
On its own,
the Recoverable protocol improves only slightly on
the traditional, NSError-based methodology
by formalizing the requirements for recovery.
Rather than implementing conforming types individually, we can generalize the functionality with some clever use of generics.
First,
define an Error protocol
that re-casts the attempt methods from before
to use an associated, Recovery type.
protocol ErrorRecoveryDelegate: class {
associatedtype RecoveryOption: CustomStringConvertible,
CaseIterable
func attemptRecovery(from error: Error,
with option: RecoveryOption) -> Bool
}
Requiring that Recovery conforms to Case,
allows us to vend options directly to API consumers
independently of their presentation to the user.
From here,
we can define a generic Delegating type
that wraps an Error type
and associates it with the aforementioned Delegate,
which is responsible for providing recovery options
and attempting recovery with the one selected.
struct DelegatingRecoverableError<Delegate, Error>: RecoverableError
where Delegate: ErrorRecoveryDelegate,
Error: Swift.Error
{
let error: Error
weak var delegate: Delegate? = nil
init(recoveringFrom error: Error, with delegate: Delegate?) {
self.error = error
self.delegate = delegate
}
var recoveryOptions: [String] {
return Delegate.RecoveryOption.allCases.map { "\($0)" }
}
func attemptRecovery(optionIndex recoveryOptionIndex: Int) -> Bool {
let recoveryOptions = Delegate.RecoveryOption.allCases
let index = recoveryOptions.index(recoveryOptions.startIndex,
offsetBy: recoveryOptionIndex)
let option = Delegate.RecoveryOption.allCases[index]
return self.delegate?.attemptRecovery(from: self.error,
with: option) ?? false
}
}
Now we can refactor the previous example of our macOS app
to have App conform to Error
and define a nested Recovery enumeration
with all of the options we wish to support.
extension AppDelegate: ErrorRecoveryDelegate {
enum RecoveryOption: String, CaseIterable, CustomStringConvertible {
case tryAgain
case giveUp
var description: String {
switch self {
case .tryAgain:
return NSLocalizedString("Try, try again",
comment: self.rawValue)
case .giveUp:
return NSLocalizedString("Give up too easily",
comment: self.rawValue)
}
}
}
func attemptRecovery(from error: Error,
with option: RecoveryOption) -> Bool
{
do {
if option == .tryAgain {
try something()
}
} catch {
window.presentError(error)
}
return true
}
func application(_ application: NSApplication, willPresentError error: Error) -> Error {
return DelegatingRecoverableError(recoveringFrom: error, with: self)
}
}
The result?
…wait, that’s not right.
What’s missing? To find out, let’s look at our third and final protocol in our discussion.
Improving Interoperability with Cocoa Error Handling System
The Custom protocol
is like an inverted NSError:
it allows a type conforming to Error
to act like it was instead an NSError subclass.
protocol CustomNSError: Error {
static var errorDomain: String { get }
var errorCode: Int { get }
var errorUserInfo: [String : Any] { get }
}
The protocol requirements correspond to the
domain, code, and user properties of an NSError, respectively.
Now, back to our modal from before:
normally, the title is taken from user via NSLocalized.
Types conforming to Localized can provide this too
through their equivalent error property.
And while we could extend Delegating
to adopt Localized,
it’s actually much less work to add conformance for Custom:
extension DelegatingRecoverableError: CustomNSError {
var errorUserInfo: [String: Any] {
return (self.error as NSError).userInfo
}
}
With this one additional step, we can now enjoy the fruits of our burden.
In programming,
it’s often not what you know,
but what you know about.
Now that you’re aware of the existence of
Localized, Recoverable, Custom,
you’ll be sure to identify situations in which they might
improve error handling in your app.
Useful AF, amiright? Then again, “Familiarity breeds contempt”; so often, what initially endears one to ourselves is what ultimately causes us to revile it.
Such is the error of our ways.