Last week we found the easiest way to retrieve localized context from an error. This gives us three values to play with:
But how do we use these to effectively communicate an error to our users?
To figure it out, we should start by looking at the invariant — the one thing out of our power to control; the values native frameworks give to their errors. Here’s a small list:
The first thing we learn is that these values are not supported equally across Apple’s libraries. While there’s always a description present, the recovery suggestion in particular is rarely used.
But we do see a pattern. Generally, the failure reason (when present) provides a short and conscience explanation of the error. Quick and to the point.
The description often restates the failure, but goes more in-depth. It might identify the particular error or some of the values involved, for example.
Finally, if the error is specific enough that a workaround can be assumed by the library author, that is written up and given in the recovery suggestion.
These usages suggest an easy mapping with the standard Alert dialog.
First we cast the incoming error as an NSError
. Then we derive a title from its failure reason (or substitute a default title in case there’s no reason specified):
let bridge = myError as NSError
let title =
bridge.localizedFailureReason
?? "An Error Occurred"
Then we take the description and the recovery suggestion (if it exists) and create a message body by joining them together with a space between:
let message = [
bridge.localizedDescription,
bridge.localizedRecoverySuggestion,
]
.compactMap { $0 }
.joined(separator: "\n\n")
Note that we don’t need to give a default here. The localized description of an NSError
will never be empty. And if there’s no recovery suggestion, we just don’t add it.
Once we have a our title and message, we can pass them along to our alert controller:
let alert = UIAlertController(
title: title,
message: message,
preferredStyle: .alert
)
When we present it, it’ll look something like:
To some long-time Cocoa developers, this may look a little strange. After all, AppKit provides a presentError(_:)
method on NSWindow
that uses the description as the title and only renders a message if there’s a recovery suggestion given:
Why not adopt this style ourselves? We’ll talk about this more next week when we dissect how to write a good error.
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.
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:
errorDescription
failureReason
recoverySuggestion
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."
}
}
}
userInfo
Good old NSError
provides a userInfo
dictionary that we can fill with anything we want. But it also provides some predefined keys:
NSLocalizedDescriptionKey
NSLocalizedFailureReasonErrorKey
NSLocalizedRecoverySuggestionErrorKey
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.
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.
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!
It’s finally here! After seven years of callbacks and completion handlers, we now have a way to cleanly and correctly make asynchronous calls in Swift without endless nesting. It’s async/await and it’s awesome.
Sadly, these new features require runtime support. Which means, at least for the time being, async is iOS 15-/macOS 12-only.
For those of us supporting older deployment targets, this can be a bit of a let down. But not all hope is lost! We can build clean, flattened-out async handling on our own. And maybe learn a thing or two about the nature of asynchrony along the way?
Let’s start with a description of the problem and how Swift 5.5 solves it.
Given a series of dependent, asynchronous calls, we currently have to write them in a bunch of nested callbacks:
func fetchPortrait(
user: UUID,
completion: (Image)->Void
) {
fetchAvatar(user: user) { url in
fetchImage(url: url) { image in
completion(image)
}
}
}
This works, but execution jumps all over the place spatially and temporally making the code hard to reason about and prone to errors. We would like to specify our async calls procedurally, instead. One right after the other, no nesting.
Swift 5.5 solves this with async/await:
func fetchPortrait(user: UUID) async -> Image {
let url = await fetchAvatar(user: user)
let image = await fetchImage(url: url)
return image
}
How are they doing that? Swift.org sheds some illumination:
An asynchronous function … is a special kind of function that can be suspended while it’s partway through execution.
Long time readers will recognize this as a coroutine. A coroutine is ideal for handling asynchronous work because we can suspend it just after it begins its async job, then resume it again right where it left off when the job completes and we’re ready to continue.
This flow of events might sound familiar. Let’s look a little closer look at the execution of a standard “completion handler” function:
func prettyHTML(
url: URL,
completion: (HTML)->Void
) {
getPage(url: url) { html in
// Do work to format the HTML...
completion(html)
}
}
In this example, prettyHTML
starts getting some HTML via getPage
… and then returns. Some time later, the closure we passed to getPage
is called, and work is done to pretty up the HTML.
In other words, prettyHTML
is essentially “suspended while it’s partway through execution.” And then, at some later time, its execution is resumed again from that very spot it left off.
Yup. Completions handlers are the poor man’s coroutine. They just require that we bundle up all the state needed to “resume the coroutine” in a closure. It’s that closure that leads to the nesting and non-linear execution flow.
So we already have the building blocks for decent async handling. What we really need is a better way to manage those completion closures.
So let’s zoom in on our completion handlers. Imagine the simple function:
func fetchAvatar(user: UUID) -> URL
If we want to make this asynchronous by rewriting it as a “cheap coroutine” with completion handler, what do we need to do? Rather than returning the URL
we’ll move it to the argument of a new closure we pass in as a parameter:
func fetchAvatar(user: UUID, complete: (URL)->Void)
There’s a name for rewriting functions this way — it’s called Continuation Passing Style or CPS. And the closure we pass when we do this is called a continuation. Which makes sense — it’s the thing we call to “continue” where we left off.
Let’s make a type to represent a continuation:
typealias Continuation<Ret> = (Ret) -> Void
Our CPS version of fetchAvatar
could then look like:
func fetchAvatar(user: UUID, c: Continuation<URL>)
In fact, if we rewrite the signature of all the functions from our async example, above, we might notice something striking:
func fetchPortrait(
user: UUID,
c: Continuation<Image>
)
func fetchAvatar(user: UUID, c: Continuation<URL>)
func fetchImage(url: URL, c: Continuation<Image>)
They all take a Continuation
type — the only difference is the generic type representing the value they’ll eventually be called with. If we had a way to transform a Continuation<URL>
to a Continuation<Image>
, we could chain these together!
In other words, given a Continuation<URL>
we want a way to apply a transformation (T)->Continuation<U>
that results in a new Continuation<Image>
. This is a flatMap
. And flatMaps are the domain of Monads.
A Continuation Monad wraps a Continuation
. That is, it’s what happens when you render a Continuation
, itself, in continuation passing style.
If a Continuation
, conceptually, rewrites a value as a function that takes said value as its parameter, then a continuation of a Continuation
is a Continuation
rewritten as a function that takes said Continuation
as its parameter.
Deep breath.
Okay, let’s to try to illustrate this in code:
// A plain old Value:
Value
// Continuation of Value:
(Value)->Void
// Continuation of Continuation of Value:
((Value)->Void)->Void
Defining types for these abstractions as we go will help keep things clear. ContinuationMonad
is a continuation of a Continuation
:
typealias ContinuationMonad<Value> =
(@escaping Continuation<Value>) -> Void
We can create one of these monads with a function that takes the value we eventually want to pass on to the continuation. We can name this function anything we like. init
, makeMonad
, unit
… Khanlou’s post on monads covers a lot of various names traditionally used for monadic operations.
Here, we’re going to name it async
:
func async<Value>(_ wrappedValue: Value)
-> ContinuationMonad<Value> {
return { continuation in
continuation(wrappedValue)
}
}
This returns a function that, when called with a continuation, calls the continuation with the wrappedValue
passed in on creation. In other words, this returns a ContinuationMonad
.
Or course, our whole goal is to be able to transform between different types of continuations. In the context of a ContinuationMonad
, we want to be able to, given a value T
from the source monad, create a new monad with a transformed value U
. Which is to say:
typealias Transform<T,U> =
(T) -> ContinuationMonad<U>
We apply this transform with our friend, flatMap
. But the implementation of a flatMap
over a continuation of a Continuation
is a little intense:
func flatMap<T,U>(
_ monad: @escaping ContinuationMonad<T>,
_ transform: @escaping Transform<T,U>
) -> ContinuationMonad<U> {
return { continuation in
monad { wrappedValue in
transform(wrappedValue)(continuation)
}
}
}
Given a ContinuationMonad
and a Transform
, we return a function that, when called with a Continuation
, will:
Transform
, and then…If this causes headaches, we are, at least, in very good company. My best advice is to throw it into a playground and step over it a couple or hundred times. Enlightenment will follow in its own measure.
Once we get over the raw mechanics, the important thing for us to recognize is none of these transformations are actually applied at the time we call flatMap
. We return a thing that will call the thing that will apply the transformations when the continuation is passed in. In other words, everything is still asynchronous.
With the ContinuationMonad
to guide us, our fetchPortrait
implementation can create a new monad wrapping the given UUID
, then flatMap
it into our fetchAvatar
operation:
// We’ll make this better. Promise!
func fetchPortrait(
user: UUID,
c: @escaping Continuation<String>
) {
flatMap(
async(user),
{ aUser in
{ aContinuation in
fetchAvatar(user: t, c: u)
}
}
)
//...
}
Yikes! What happened in the second half of our flatMap
?
Well, remember the flatMap
takes a Transform
that looks like :
// Transform’s definition:
(T)->ContinuationMonad<U>
or, if we break it down a few steps further:
// Substitute ContinuationMonad’s definition:
(T)->(Continuation<U>)->Void
// Substitute Continuation’s definition:
(T)->((U)->Void)->Void
That’s a tricky shape to fill. And our fetchAvatar
’s signature doesn’t match it:
(UUID, (URL)->Void)->Void
Though, actually, if we pull that first UUID argument off of the front, creating new function that takes only the UUID, returning a function that takes what remains of the original parameters… that fits the shape we need exactly:
// What we need:
(T)->((U)->Void)->Void
// fetchAvatar after pulling first argument
// into its own function:
(UUID)->((URL)->Void)->Void
There’s a functional programming concept called “currying” that does precisely this. The details are probably worth a blog post on their own, and the Point•Free folk have a really good series on this if you want to know more.
But for now, suffice it to say that, given this function:
public func curry<T, U, Z>(
_ ƒ: @escaping (T, U)->Z
) -> (T) -> (U) -> Z {
{ t in { u in ƒ(t, u) } }
}
and some function that takes two arguments like:
func example(_ i: Int, _ d: Double) -> String {
"\(i):\(d)"
}
both these expressions are equivalent:
example(42, 33.3) //> "42:33.3"
curry(example)(42)(33.3) //> "42:33.3"
Which lets us rewrite our messy flatMap
above as:
func fetchPortrait(
user: UUID,
c: @escaping Continuation<String>
) {
flatMap(
flatMap(
async(user),
curry(fetchAvatar)
),
curry(fetchImage)
)
}
Much better!
But hold the phone… the whole point of this was to get rid of nesting in our callbacks, and here we are nesting a bunch of flatMap
s instead? How is this an improvement?
In short, there’s a big difference between nested closure implementations (and their captured environments) and nesting identical functions with identical signatures; not the least of which is: we can chain redundant function calls by introducing an operator.
When it comes to declaring an operator, there can be a lot of sturm and drang over the choice of symbol. Haskell uses >>=
for a flatMap
. That’s good enough for me:
precedencegroup FlatMapOperator {
associativity: left
}
infix operator >>=: FlatMapOperator
func >>=<T,U> (
lhs: @escaping ContinuationMonad<T>,
rhs: @escaping ContinuationMonadTransform<T,U>
) -> ContinuationMonad<U> {
return flatMap(lhs, rhs)
}
With this, we can rewrite our monad chain, above, as:
async(user)
>>= curry(fetchAvatar)
>>= curry(fetchImage)
Which is feeling pretty nice. Of course, this returns a ContinuationMonad
which, as we mentioned above, doesn’t actually do anything on its own. To get it jump into action, we need to give it a continuation.
We could do that with a trailing closure:
(async(user)
>>= curry(fetchAvatar)
>>= curry(fetchImage)) { image in
logImage(image)
}
But more often than not, we’ll have a parent continuation we’ll want to resume when the monad chain completes. Because it’s already a continuation, we don’t need to make a new closure to hold it. We can just apply it to the preceding function.
To make this look pretty, we’ll add a backwards application operator. Haskell uses $
for this, which is problematic in Swift for a number of reasons. So we’re going to steal F#’s <|
instead:
precedencegroup BackApplication {
associativity: left
}
infix operator <|: BackApplication
public func <| <T, U>(ƒ: (T)->U, t: T) -> U {
return ƒ(t)
}
Now we can chain async operations on our monad with >>=
and apply a continuation (which will be called, asynchronously, with the result of the whole monad chain) with <|
:
async(user)
>>= curry(fetchAvatar)
>>= curry(fetchImage)
<| logImage
Not so long ago, we set out on our quest with nothing but a nested mess of async spaghetti:
func fetchPortrait(
user: UUID,
completion: (Image)->Void
) {
fetchAvatar(user: user) { url in
fetchImage(url: url) { image in
completion(image)
}
}
}
But with a little understanding of continuations (and a handful of monads and operators on loan from functional languages), we’ve ended up with:
func fetchPortrait(
user: UUID,
completion: Continuation<Image>
) {
async(user)
>>= curry(fetchAvatar)
>>= curry(fetchImage)
<| completion
}
Not bad! Makes me feel like there’s plenty of gas left in the tank of the ol’ runtime to get us through these golden years of iOS 12–14 support.
This was a long one with a lot of scattered code, so I’ve pulled everything together into a gist. You ought to be able to paste it right into a Playground and get results — at least in Xcode 12.
subscribe via RSS