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 *)replace Item At URL:(NSURL *)url
options:(NSFile Version Replacing Options)options
error:(NSError * _Nullable *)error;
func replace Item(at url: URL,
options: NSFile Version.Replacing Options = []) 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:
Localized
Error -
A specialized error that provides localized messages describing the error and why it occurred.
Recoverable
Error -
A specialized error that may be recoverable by presenting several potential recovery options to the user.
Custom
NSError -
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 too Many Cooks(Int)
}
init(number Of Cooks: Int) throws {
precondition(number Of Cooks > 0)
guard number Of Cooks < redacted else {
throw Error.too Many Cooks(number Of Cooks)
}
// ... 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 View Controller: UIView Controller {
override func view Did Appear(_ animated: Bool) {
super.view Did Appear(animated)
do {
self.broth = try Broth(number Of Cooks: 100)
} catch let error as Broth.Error {
let title: String
let message: String
switch error {
case .too Many Cooks(let number Of Cooks):
title = "Too Many Cooks (\(number Of Cooks))"
message = """
It's difficult to reconcile many opinions.
Reduce the number of decision makers.
"""
}
let alert Controller =
UIAlert Controller(title: title,
message: message,
preferred Style: .alert)
alert Controller.add Action(
UIAlert Action(title: "OK",
style: .default)
)
self.present(alert Controller, 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 Localized Error : Error {
var error Description: String? { get }
var failure Reason: String? { get }
var recovery Suggestion: String? { get }
var help Anchor: 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: Localized Error {
var error Description: String? {
switch self {
case .too Many Cooks(let number Of Cooks):
return "Too Many Cooks (\(number Of Cooks))"
}
}
var failure Reason: String? {
switch self {
case .too Many Cooks:
return "It's difficult to reconcile many opinions."
}
}
var recovery Suggestion: String? {
switch self {
case .too Many Cooks:
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 View Controller: UIView Controller {
override func view Did Appear(_ animated: Bool) {
super.view Did Appear(animated)
do {
try make Broth(number Of Cooks: 100)
} catch let error as Localized Error {
let title = error.error Description
let message = [
error.failure Reason,
error.recovery Suggestion
].compact Map { $0 }
.joined(separator: "\n\n")
let alert Controller =
UIAlert Controller(title: title,
message: message,
preferred Style: .alert)
alert Controller.add Action(
UIAlert Action(title: "OK",
style: .default)
)
self.present(alert Controller, 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 App Kit
@NSApplication Main
class App Delegate: NSObject, NSApplication Delegate {
@IBOutlet weak var window: NSWindow!
func application Did Finish Launching(_ a Notification: Notification) {
do {
_ = try something()
} catch {
window.present Error(error)
}
}
func something() throws -> Never {
let user Info: [String: Any] = [
NSLocalized Description Key:
NSLocalized String("The operation couldn’t be completed.",
comment: "localized Error Description"),
NSLocalized Recovery Suggestion Error Key:
NSLocalized String("If at first you don't succeed...",
comment: "localized Error Recover Suggestion")
]
throw NSError(domain: "com.nshipster.error", code: 1, user Info: user Info)
}
}
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 App Delegate {
func application(_ application: NSApplication,
will Present Error error: Error) -> Error
{
var user Info: [String: Any] = (error as NSError).user Info
user Info[NSLocalized Recovery Options Error Key] = [
NSLocalized String("Try, try again",
comment: "try Again")
NSLocalized String("Give up too easily",
comment: "give Up")
]
user Info[NSRecovery Attempter Error Key] = self
return NSError(domain: (error as NSError).domain,
code: (error as NSError).code,
user Info: user Info)
}
}
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 App Delegate {
// MARK: NSError Recovery Attempting
override func attempt Recovery(from Error error: Error,
option Index recovery Option Index: Int) -> Bool
{
do {
switch recovery Option Index {
case 0: // Try, try again
try something()
case 1:
fallthrough
default:
break
}
} catch {
window.present Error(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 Recoverable Error : Error {
var recovery Options: [String] { get }
func attempt Recovery(option Index recovery Option Index: Int, result Handler handler: @escaping (Bool) -> Void)
func attempt Recovery(option Index recovery Option Index: 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 Error Recovery Delegate: class {
associatedtype Recovery Option: Custom String Convertible,
Case Iterable
func attempt Recovery(from error: Error,
with option: Recovery Option) -> 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 Delegating Recoverable Error<Delegate, Error>: Recoverable Error
where Delegate: Error Recovery Delegate,
Error: Swift.Error
{
let error: Error
weak var delegate: Delegate? = nil
init(recovering From error: Error, with delegate: Delegate?) {
self.error = error
self.delegate = delegate
}
var recovery Options: [String] {
return Delegate.Recovery Option.all Cases.map { "\($0)" }
}
func attempt Recovery(option Index recovery Option Index: Int) -> Bool {
let recovery Options = Delegate.Recovery Option.all Cases
let index = recovery Options.index(recovery Options.start Index,
offset By: recovery Option Index)
let option = Delegate.Recovery Option.all Cases[index]
return self.delegate?.attempt Recovery(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 App Delegate: Error Recovery Delegate {
enum Recovery Option: String, Case Iterable, Custom String Convertible {
case try Again
case give Up
var description: String {
switch self {
case .try Again:
return NSLocalized String("Try, try again",
comment: self.raw Value)
case .give Up:
return NSLocalized String("Give up too easily",
comment: self.raw Value)
}
}
}
func attempt Recovery(from error: Error,
with option: Recovery Option) -> Bool
{
do {
if option == .try Again {
try something()
}
} catch {
window.present Error(error)
}
return true
}
func application(_ application: NSApplication, will Present Error error: Error) -> Error {
return Delegating Recoverable Error(recovering From: 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 Custom NSError: Error {
static var error Domain: String { get }
var error Code: Int { get }
var error User Info: [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 Delegating Recoverable Error: Custom NSError {
var error User Info: [String: Any] {
return (self.error as NSError).user Info
}
}
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.