Quantcast
Channel: NSHipster
Viewing all articles
Browse latest Browse all 382

LocalizedError, RecoverableError, CustomNSError

$
0
0

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 ErrorProtocol 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*)urloptions:(NSFileVersionReplacingOptions)optionserror:(NSError*_Nullable*)error;

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 ErrorProtocol), 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 LocalizedError in the wild? How about RecoverableError? CustomNSErrorqu’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 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 tooManyCooks(Int)
        }init(numberOfCooks:Int)throws{precondition(numberOfCooks>0)guardnumberOfCooks<redactedelse{throwError.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 UIAlertController in a catch statement like this:

importUIKitclassViewController:UIViewController{overridefuncviewDidAppear(_animated:Bool){super.viewDidAppear(animated)do{self.broth=tryBroth(numberOfCooks:100)}catchleterrorasBroth.Error{lettitle:Stringletmessage:Stringswitcherror{case .tooManyCooks(let numberOfCooks):
        title="Too Many Cooks (\(numberOfCooks))"message="""
        It's difficult to reconcile many opinions.
        Reduce the number of decision makers.
        """}letalertController=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, 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 LocalizedError protocol inherits the base Error protocol and adds four instance property requirements.

protocolLocalizedError:Error{varerrorDescription:String?{get}varfailureReason:String?{get}varrecoverySuggestion:String?{get}varhelpAnchor:String?{get}}

These properties map 1:1 with familiar NSErroruserInfo keys.

RequirementUser Info Key
errorDescriptionNSLocalizedDescriptionKey
failureReasonNSLocalizedFailureReasonErrorKey
recoverySuggestionNSLocalizedRecoverySuggestionErrorKey
helpAnchorNSHelpAnchorErrorKey

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 LocalizedError conformance.

importFoundationextensionBroth.Error:LocalizedError{varerrorDescription:String?{switchself{case .tooManyCooks(let numberOfCooks):
        return"Too Many Cooks (\(numberOfCooks))"}}varfailureReason:String?{switchself{case .tooManyCooks:
        return"It's difficult to reconcile many opinions."}}varrecoverySuggestion:String?{switchself{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 numberOfCooks constant to the associated value only when it’s necessary.

Now we can

importUIKitclassViewController:UIViewController{overridefuncviewDidAppear(_animated:Bool){super.viewDidAppear(animated)do{trymakeBroth(numberOfCooks:100)}catchleterrorasLocalizedError{lettitle=error.errorDescriptionletmessage=[error.failureReason,error.recoverySuggestion].compactMap{$0}.joined(separator:"\n\n")letalertController=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...}}}

iOS alert modal


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 presentError(_:) method, called on an NSWindow.

importAppKit@NSApplicationMainclassAppDelegate:NSObject,NSApplicationDelegate{@IBOutletweakvarwindow:NSWindow!funcapplicationDidFinishLaunching(_aNotification:Notification){do{_=trysomething()}catch{window.presentError(error)}}funcsomething()throws->Never{letuserInfo:[String:Any]=[NSLocalizedDescriptionKey:NSLocalizedString("The operation couldn’t be completed.",comment:"localizedErrorDescription"),NSLocalizedRecoverySuggestionErrorKey:NSLocalizedString("If at first you don't succeed...",comment:"localizedErrorRecoverSuggestion")]throwNSError(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.

Default macOS error modal

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 userInfo keys NSRecoveryAttempterErrorKey and NSRecoveryAttempterErrorKey. A great way to do that is to override the application(_:willPresentError:) delegate method and intercept and modify an error before it’s presented to the user.

extensionAppDelegate{funcapplication(_application:NSApplication,willPresentErrorerror:Error)->Error{varuserInfo:[String:Any]=(errorasNSError).userInfouserInfo[NSLocalizedRecoveryOptionsErrorKey]=[NSLocalizedString("Try, try again",comment:"tryAgain")NSLocalizedString("Give up too easily",comment:"giveUp")]userInfo[NSRecoveryAttempterErrorKey]=selfreturnNSError(domain:(errorasNSError).domain,code:(errorasNSError).code,userInfo:userInfo)}}

For NSLocalizedRecoveryOptionsErrorKey, specify an array of one or more localized strings for each recovery option available the user.

For NSRecoveryAttempterErrorKey, set an object that implements the attemptRecovery(fromError:optionIndex:) method.

extensionAppDelegate{// MARK: NSErrorRecoveryAttemptingoverridefuncattemptRecovery(fromErrorerror:Error,optionIndexrecoveryOptionIndex:Int)->Bool{do{switchrecoveryOptionIndex{case 0: // Try, try again
        trysomething()case 1:
        fallthroughdefault:break}}catch{window.presentError(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.

Recoverable macOS error modal

Cool as that is, it carries some pretty gross baggage. First, the attemptRecovery 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 RecoverableError protocol, like LocalizedError is a refinement on the base Error protocol with the following requirements:

protocolRecoverableError:Error{varrecoveryOptions:[String]{get}funcattemptRecovery(optionIndexrecoveryOptionIndex:Int,resultHandlerhandler:@escaping(Bool)->Void)funcattemptRecovery(optionIndexrecoveryOptionIndex:Int)->Bool}

Also like LocalizedError, these requirements map onto error userInfo keys (albeit not as directly).

RequirementUser Info Key
recoveryOptionsNSLocalizedRecoveryOptionsErrorKey
attemptRecovery(optionIndex:_:)
attemptRecovery(optionIndex:)
NSRecoveryAttempterErrorKey*

The recoveryOptions property requirement is equivalent to the NSLocalizedRecoveryOptionsErrorKey: an array of strings that describe the available options.

The attemptRecovery functions formalize the previously informal delegate protocol; func attemptRecovery(optionIndex:) is for “application” granularity, whereas attemptRecovery(optionIndex:resultHandler:) is for “document” granularity.

Supplementing RecoverableError with Additional Types

On its own, the RecoverableError 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 ErrorRecoveryDelegate protocol that re-casts the attemptRecovery methods from before to use an associated, RecoveryOption type.

protocolErrorRecoveryDelegate:class{associatedtypeRecoveryOption:CustomStringConvertible,CaseIterablefuncattemptRecovery(fromerror:Error,withoption:RecoveryOption)->Bool}

Requiring that RecoveryOption conforms to CaseIterable, allows us to vend options directly to API consumers independently of their presentation to the user.

From here, we can define a generic DelegatingRecoverableError 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.

structDelegatingRecoverableError<Delegate,Error>:RecoverableErrorwhereDelegate:ErrorRecoveryDelegate,Error:Swift.Error{leterror:Errorweakvardelegate:Delegate?=nilinit(recoveringFromerror:Error,withdelegate:Delegate?){self.error=errorself.delegate=delegate}varrecoveryOptions:[String]{returnDelegate.RecoveryOption.allCases.map{"\($0)"}}funcattemptRecovery(optionIndexrecoveryOptionIndex:Int)->Bool{letrecoveryOptions=Delegate.RecoveryOption.allCasesletindex=recoveryOptions.index(recoveryOptions.startIndex,offsetBy:recoveryOptionIndex)letoption=Delegate.RecoveryOption.allCases[index]returnself.delegate?.attemptRecovery(from:self.error,with:option)??false}}

Now we can refactor the previous example of our macOS app to have AppDelegate conform to ErrorRecoveryDelegate and define a nested RecoveryOption enumeration with all of the options we wish to support.

extensionAppDelegate:ErrorRecoveryDelegate{enumRecoveryOption:String,CaseIterable,CustomStringConvertible{case tryAgain
        case giveUp
        vardescription:String{switchself{case .tryAgain:
        returnNSLocalizedString("Try, try again",comment:self.rawValue)case .giveUp:
        returnNSLocalizedString("Give up too easily",comment:self.rawValue)}}}funcattemptRecovery(fromerror:Error,withoption:RecoveryOption)->Bool{do{ifoption==.tryAgain{trysomething()}}catch{window.presentError(error)}returntrue}funcapplication(_application:NSApplication,willPresentErrorerror:Error)->Error{returnDelegatingRecoverableError(recoveringFrom:error,with:self)}}

The result?

Recoverable macOS error modal with Unintelligible title

…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 CustomNSError protocol is like an inverted NSError: it allows a type conforming to Error to act like it was instead an NSError subclass.

protocolCustomNSError:Error{staticvarerrorDomain:String{get}varerrorCode:Int{get}varerrorUserInfo:[String:Any]{get}}

The protocol requirements correspond to the domain, code, and userInfo properties of an NSError, respectively.

Now, back to our modal from before: normally, the title is taken from userInfo via NSLocalizedDescriptionKey. Types conforming to LocalizedError can provide this too through their equivalent errorDescription property. And while we could extend DelegatingRecoverableError to adopt LocalizedError, it’s actually much less work to add conformance for CustomNSError:

extensionDelegatingRecoverableError:CustomNSError{varerrorUserInfo:[String:Any]{return(self.errorasNSError).userInfo}}

With this one additional step, we can now enjoy the fruits of our burden.

Recoverable macOS error modal


In programming, it’s often not what you know, but what you know about. Now that you’re aware of the existence of LocalizedError, RecoverableError, CustomNSError, 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.


Viewing all articles
Browse latest Browse all 382

Trending Articles