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*)urloptions:(NSFile Version Replacing Options)optionserror:(NSError*_Nullable*)error;
funcreplace Item(aturl: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 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 — than 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:
structBroth{enumError{case too Many Cooks(Int)
}init(number Of Cooks:Int)throws{precondition(number Of Cooks>0)guardnumber Of Cooks<redactedelse{throwError.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:
importUIKitclassView Controller:UIView Controller{overridefuncview Did Appear(_animated:Bool){super.view Did Appear(animated)do{self.broth=tryBroth(number Of Cooks:100)}catchleterrorasBroth.Error{lettitle:Stringletmessage:Stringswitcherror{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.
"""}letalert 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, and it 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.
protocolLocalized Error:Error{varerror Description:String?{get}varfailure Reason:String?{get}varrecovery Suggestion:String?{get}varhelp 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.
importFoundationextensionBroth.Error:Localized Error{varerror Description:String?{switchself{case .too Many Cooks(let number Of Cooks):
return"Too Many Cooks (\(number Of Cooks))"}}varfailure Reason:String?{switchself{case .too Many Cooks:
return"It's difficult to reconcile many opinions."}}varrecovery Suggestion:String?{switchself{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
importUIKitclassView Controller:UIView Controller{overridefuncview Did Appear(_animated:Bool){super.view Did Appear(animated)do{trymake Broth(number Of Cooks:100)}catchleterrorasLocalized Error{lettitle=error.error Descriptionletmessage=[error.failure Reason,error.recovery Suggestion].compact Map{$0}.joined(separator:"\n\n")letalert 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
.
importApp Kit@NSApplication MainclassApp Delegate:NSObject,NSApplication Delegate{@IBOutletweakvarwindow:NSWindow!funcapplication Did Finish Launching(_a Notification:Notification){do{_=trysomething()}catch{window.present Error(error)}}funcsomething()throws->Never{letuser 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")]throwNSError(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
NSRecovery
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.
extensionApp Delegate{funcapplication(_application:NSApplication,will Present Errorerror:Error)->Error{varuser Info:[String:Any]=(errorasNSError).user Infouser 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]=selfreturnNSError(domain:(errorasNSError).domain,code:(errorasNSError).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.
extensionApp Delegate{// MARK: NSError Recovery Attemptingoverridefuncattempt Recovery(from Errorerror:Error,option Indexrecovery Option Index:Int)->Bool{do{switchrecovery Option Index{case 0: // Try, try again
trysomething()case 1:
fallthroughdefault:break}}catch{window.present Error(error)}returntrue}}
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:
protocolRecoverable Error:Error{varrecovery Options:[String]{get}funcattempt Recovery(option Indexrecovery Option Index:Int,result Handlerhandler:@escaping(Bool)->Void)funcattempt Recovery(option Indexrecovery 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.
protocolError Recovery Delegate:class{associatedtypeRecovery Option:Custom String Convertible,Case Iterablefuncattempt Recovery(fromerror:Error,withoption: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.
structDelegating Recoverable Error<Delegate,Error>:Recoverable ErrorwhereDelegate:Error Recovery Delegate,Error:Swift.Error{leterror:Errorweakvardelegate:Delegate?=nilinit(recovering Fromerror:Error,withdelegate:Delegate?){self.error=errorself.delegate=delegate}varrecovery Options:[String]{returnDelegate.Recovery Option.all Cases.map{"\($0)"}}funcattempt Recovery(option Indexrecovery Option Index:Int)->Bool{letrecovery Options=Delegate.Recovery Option.all Casesletindex=recovery Options.index(recovery Options.start Index,offset By:recovery Option Index)letoption=Delegate.Recovery Option.all Cases[index]returnself.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.
extensionApp Delegate:Error Recovery Delegate{enumRecovery Option:String,Case Iterable,Custom String Convertible{case try Again
case give Up
vardescription:String{switchself{case .try Again:
returnNSLocalized String("Try, try again",comment:self.raw Value)case .give Up:
returnNSLocalized String("Give up too easily",comment:self.raw Value)}}}funcattempt Recovery(fromerror:Error,withoption:Recovery Option)->Bool{do{ifoption==.try Again{trysomething()}}catch{window.present Error(error)}returntrue}funcapplication(_application:NSApplication,will Present Errorerror:Error)->Error{returnDelegating 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.
protocolCustom NSError:Error{staticvarerror Domain:String{get}varerror Code:Int{get}varerror 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
:
extensionDelegating Recoverable Error:Custom NSError{varerror User Info:[String:Any]{return(self.errorasNSError).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.