Practical Localized Error Values in Swift

How many times have we stared at code like:

do {
  try writeEverythingToDisk()
} catch let error {
  // ???
}

or even:

switch result {
case .failure(let error):
  // ???
}

and asked ourselves “How am I going to communicate that error?”

The thing is, that error likely contains a lot of information that could help us out. But getting to it is not always straight forward.

To see why, let’s look at the techniques at our disposal for attaching information to errors.

The New: LocalizedError

In Swift we pass around errors that conform to the Error protocol. The LocalizedError protocol inherits from this and extends it with some useful properties:

By conforming to LocalizedError instead of Error (and providing an implementation for these properties), we can stuff our error with a bunch of useful information that can be communicated at runtime (NSHipster goes way more in-depth on this):

enum MyError: LocalizedError {
  case badReference

  var errorDescription: String? {
    switch self {
    case .badReference:
      return "The reference was bad."
    }
  }
    
  var failureReason: String? {
    switch self {
    case .badReference:
      return "Bad Reference"
    }
  }

  var recoverySuggestion: String? {
    switch self {
    case .badReference:
      return "Try using a good one."
    }
  }
}

The Old: userInfo

Good old NSError provides a userInfo dictionary that we can fill with anything we want. But it also provides some predefined keys:

We might note these are very similar in name to the properties LocalizedError provides. And, in fact, they perform a similar role:

let info = [ 
  NSLocalizedDescriptionKey:
    "The reference was bad.",
  NSLocalizedFailureReasonErrorKey:
    "Bad Reference",
  NSLocalizedRecoverySuggestionErrorKey:
    "Try using a good one."
]

let badReferenceNSError = NSError(
  domain: "ReferenceDomain", 
  code: 42, 
  userInfo: info
)

So seems like LocalizedError and NSError ought to be mostly interchangeable then, right? Well, therein lies the rub.

The Old Meets the New

See, NSError implements Error, but not LocalizedError. Which is to say:

badReferenceNSError is NSError        //> true
badReferenceNSError is Error          //> true
badReferenceNSError is LocalizedError //> false

This means if we try to get data out of some unknown error in the obvious way, it works as expected for Error and LocalizedError, but only reports a localizedDescription for NSError:

// The obvious way that doesn’t work:
func log(error: Error) {
  print(error.localizedDescription)
  if let localized = error as? LocalizedError {
    print(localized.failureReason)
    print(localized.recoverySuggestion)
  }
}

log(error: MyError.badReference)
//> The reference was bad.
//> Bad Reference
//> Try using a good one.

log(error: badReferenceNSError)
//> The reference was bad.

That’s pretty annoying because we know our NSError has a failure reason and recovery suggestion defined in its userInfo. It’s just, for whatever reason, not exposed via LocalizedError conformance.

The New Becomes the Old

At this point we may despair, picturing long switch statements trying to sort types and test existence of various userInfo properties. But never fear! There is an easy solution. It’s just non-obvious.

See, NSError has convenience methods defined on it for extracting localized description, failure reason, and recovery suggestion from the userInfo:

badReferenceNSError.localizedDescription
//> "The reference was bad."

badReferenceNSError.localizedFailureReason
//> "Bad Reference"

badReferenceNSError.localizedRecoverySuggestion
//> "Try using a good one."

Which is great for handling an NSError, but doesn’t help us get these values out of a Swift LocalizedError… or does it?

It turns out Swift’s Error is bridged by the compiler to NSError. Which means we can treat an Error as an NSError with a simple cast:

let bridgedError: NSError
bridgedError = MyError.badReference as NSError

More impressively, though, when we cast a LocalizedError in this way, the bridge does the right thing and wires up localizedDescription, localizedFailureReason, and localizedRecoverySuggestion to point to the appropriate values!

So if we want a consistent interface to pull localized information out of Error, LocalizedError, and NSError, we just need to blindly cast everything to an NSError first:

func log(error: Error) {
  let bridge = error as NSError
  print(bridge.localizedDescription)
  print(bridge.localizedFailureReason)
  print(bridge.localizedRecoverySuggestion)
}

log(error: MyError.badReference)
//> The reference was bad.
//> Bad Reference
//> Try using a good one.

log(error: badReferenceNSError)
//> The reference was bad.
//> Bad Reference
//> Try using a good one.

Once we have all this delicious context about our errors, what should we do with it? That’s the topic of next week’s post. See you then!