Optional, throws, Result, async/await
Back in the early days of Swift 1, we didn’t have much in the way of error handling.
But we did have Optional
,
and it felt awesome!
By making null checks explicit and enforced,
bombing out of a function by returning nil
suddenly felt less like a code smell
and more like a language feature.
Here’s how we might write a little utility to grab Keychain data returning nil
for any errors:
func keychain Data(service: String) -> Data? {
let query: NSDictionary = [
k Sec Class: k Sec Class Generic Password,
k Sec Attr Service: service,
k Sec Return Data: true
]
var ref: CFType Ref? = nil
switch Sec Item Copy Matching(query, &ref) {
case err Sec Success:
return ref as? Data
default:
return nil
}
}
We set up a query,
pass an an empty inout
reference to Sec
and then,
depending on the status code we get back,
either return the reference as data
or nil
if there was an error.
At the call site, we can tell if something has exploded by unwrapping the optional:
if let my Data = keychain Data(service: "My Service") {
do something with my Data...
} else {
fatal Error("Something went wrong with... something?")
}
Getting Results
There’s a certain binary elegance to the above,
but it conceals an achilles heel.
At its heart,
Optional
is just an enum that holds either some wrapped value or nothing:
enum Optional<Wrapped> {
case some(Wrapped)
case none
}
This works just fine for our utility when everything goes right — we just return our value.
But most operations that involve I/O can go wrong
(Sec
, in particular, can go wrong many, many ways),
and Optional
ties our hands when it comes to signaling something’s gone sideways.
Our only option is to return nothing.
Meanwhile, at the call site, we’re wondering what the issue is and all we’ve got to work with is this empty .none
.
It’s difficult to write robust software when every non-optimal condition is essentially reduced to ¯\_(ツ)_/¯
.
How could we improve this situation?
One way would be to add some language-level features that let functions throw errors in addition to returning values.
And this is, in fact, exactly what Swift did in version 2 with its throws/throw
and do/catch
syntax.
But let’s stick with our Optional
line of reasoning for just a moment. If the issue is that Optional
can only hold a value or nil
, and in the event of an error nil
isn’t expressive enough, maybe we can address the issue simply by making a new Optional
that holds either a value or an error?
Well, congratulations are in order:
change a few names, and we see we just invented the new Result
type,
now available in the Swift 5 standard library!
enum Result<Success, Failure: Error> {
case success(Success)
case failure(Failure)
}
Result
holds either a successful value or an error.
And we can use it to improve our little keychain utility.
First,
let’s define a custom Error
type with some more descriptive cases than a simple nil
:
enum Keychain Error: Error {
case not Data
case not Found(name: String)
case io Went Bad
…
}
Next we change our keychain
definition to return Result<Data, Error>
instead of Data?
.
When everything goes right we return our data as the associated value of a .success
.
What happens if any of Sec
’s many and varied disasters strike?
Rather than returning nil
we return one of our specific errors wrapped in a .failure
:
func keychain Data(service: String) -> Result<Data, Error> {
let query: NSDictionary = [...]
var ref: CFType Ref? = nil
switch Sec Item Copy Matching(query, &ref) {
case err Sec Success:
guard let data = ref as? Data else {
return .failure(Keychain Error.not Data)
}
return .success(data)
case err Sec Item Not Found:
return .failure(Keychain Error.not Found(name: service))
case err Sec IO:
return .failure(Keychain Error.io Went Bad)
…
}
}
Now we have a lot more information to work with at the call site! We can, if we choose, switch
over the result, handling both success and each error case individually:
switch keychain Data(service: "My Service") {
case .success(let data):
do something with data...
case .failure(Keychain Error.not Found(let name)):
print("\(name) not found in keychain.")
case .failure(Keychain Error.io Went Bad):
print("Error reading from the keychain.")
case .failure(Keychain Error.not Data):
print("Keychain is broken.")
…
}
All things considered, Result
seems like a pretty useful upgrade to Optional
. How on earth did it take it five years to be added to the standard library?
Three’s a Crowd
Alas, Result
is also cursed with an achilles heel — we just haven’t noticed it yet because, up until now, we’ve only been working with a single call to a single function. But imagine we add two more error-prone operations to our list of Result
-returning utilities:
func make Avatar(from user: Data) -> Result<UIImage, Error> {
Return avatar made from user's initials...
or return failure...
}
func save(image: UIImage) -> Result<Void, Error> {
Save image and return success...
or returns failure...
}
In our example,
the first function generates an avatar from user data,
and the second writes an image to disk.
The implementations don’t matter so much for our purposes,
just that they return a Result
type.
Now, how would we write something that fetches user data from the keychain, uses it to create an avatar, saves that avatar to disk, and handles any errors that might occur along the way?
We might try something like:
switch keychain Data(service: "User Data") {
case .success(let user Data):
switch make Avatar(from: user Data) {
case .success(let avatar):
switch save(image: avatar) {
case .success:
break // continue on with our program...
case .failure(File System Error.read Only):
print("Can't write to disk.")
…
}
case .failure(Avatar Error.invalid User Format):
print("Unable to generate avatar from given user.")
…
}
case .failure(Keychain Error.not Found(let name)):
print(""\(name)" not found in keychain.")
…
}
But whooo boy. Adding just two functions has led to an explosion of nesting, dislocated error handling, and woe.
Falling Flat
Thankfully, we can clean this up by taking advantage of the fact that,
like Optional
,
Result
implements flat
.
Specifically, flat
on a Result
will,
in the case of .success
,
apply the given transform to the associated value and return the newly produced Result
.
In the case of a .failure
, however,
flat
simply passes the .failure
and its associated error along without modification.
Because it passes errors through in this manner, we can use flat
to combine our operations together without checking for .failure
each step of the way. This lets us minimize nesting and keep our error handling and operations distinct:
let result = keychain Data(service: "User Data")
.flat Map(make Avatar)
.flat Map(save)
switch result {
case .success:
break // continue on with our program...
case .failure(Keychain Error.not Found(let name)):
print(""\(name)" not found in keychain.")
case .failure(Avatar Error.invalid User Format):
print("Unable to generate avatar from given user.")
case .failure(File System Error.read Only):
print("Can't write to disk.")
…
}
This is, without a doubt, an improvement. But it requires us (and anyone reading our code) to be familiar enough with .flat
to follow its somewhat unintuitive semantics.
Compare this to the do/catch
syntax from all the way back in Swift 2 that we alluded to a little earlier:
do {
let user Data = try keychain Data(service: "User Data")
let avatar = try make Avatar(from: user Data)
try save(image: avatar)
} catch Keychain Error.not Found(let name) {
print(""\(name)" not found in keychain.")
} catch Avatar Error.invalid User Format {
print("Not enough memory to create avatar.")
} catch File System Error.read Only {
print("Could not save avatar to read-only media.")
} …
The first thing that might stand out is how similar these two pieces of code are. They both have a section up top for executing our operations. And both have a section down below for matching errors and handling them.
Whereas the Result
version has us piping operations through chained calls to flat
,
we write the do/catch
code more or less exactly as we would if no error handling were involved.
While the Result
version requires we understand the internals of its enumeration
and explicitly switch
over it to match errors,
the do/catch
version lets us focus on the part we actually care about:
the errors themselves.
By having language-level syntax for error handling, Swift effectively masks all the Result
-related complexities it took us the first half of this post to digest: enumerations, associated values, generics, flatMap, monads… In some ways, Swift added error-handling syntax back in version 2 specifically so we wouldn’t have to deal with Result
and its eccentricities.
Yet here we are, five years later, learning all about it. Why add it now?
Error’s Ups and Downs
Well, as it should happen, do/catch
has this little thing we might call an achilles heel…
See, throw
, like return
, only works in one direction; up. We can throw
an error “up” to the caller, but we can’t throw
an error “down” as a parameter to another function we call.
This “up”-only behavior is typically what we want.
Our keychain utility,
rewritten once again with error handling,
is all return
s and throw
s because its only job is passing either our data or an error
back up to the thing that called it:
func keychain Data(service: String) throws -> Data {
let query: NSDictionary = [...]
var ref: CFType Ref? = nil
switch Sec Item Copy Matching(query, &ref) {
case err Sec Success:
guard let data = ref as? Data else {
throw Keychain Error.not Data
}
return data
case err Sec Item Not Found:
throw Keychain Error.not Found(name: service)
case err Sec IO:
throw Keychain Error.io Went Bad
…
}
}
But what if, instead of fetching user data from the keychain, we want to get it from a cloud service? Even on a fast, reliable connection, loading data over a network can take a long time compared to reading it from disk. We don’t want to block the rest of our application while we wait, of course, so we’ll make it asynchronous.
But that means we’re no longer returning anything “up”. Instead we’re calling “down” into a closure on completion:
func user Data(for user ID: String, completion: (Data) -> Void) {
get data from the network
// Then, sometime later:
completion(my Data)
}
Now network operations can fail with all sorts of different errors,
but we can’t throw
them “down” into completion
.
So the next best option is to pass any errors along as a second (optional) parameter:
func user Data(for user ID: String, completion: (Data?, Error?) -> Void) {
Fetch data over the network...
guard my Error == nil else {
completion(nil, my Error)
}
completion(my Data, nil)
}
But now the caller, in an effort to make sense of this cartesian maze of possible parameters, has to account for many impossible scenarios in addition to the ones we actually care about:
user Data(for: "jemmons") { maybe Data, maybe Error in
switch (maybe Data, maybe Error) {
case let (data?, nil):
do something with data...
case (nil, URLError.timed Out?):
print("Connection timed out.")
case (nil, nil):
fatal Error("🤔Hmm. This should never happen.")
case (_?, _?):
fatal Error("😱What would this even mean?")
…
}
}
It’d be really helpful if, instead of this mishmash of “data or nil and error or nil” we had some succinct way to express simply “data or error”.
Stop Me If You’ve Heard This One…
Wait, data or error?
That sounds familiar.
What if we used a Result
?
func user Data(for user ID: String, completion: (Result<Data, Error>) -> Void) {
// Everything went well:
completion(.success(my Data))
// Something went wrong:
completion(.failure(my Error))
}
And at the call site:
user Data(for: "jemmons") { result in
switch (result) {
case (.success(let data)):
do something with data...
case (.failure(URLError.timed Out)):
print("Connection timed out.")
…
}
Ah ha!
So we see that the Result
type can serve as a concrete reification of Swift’s abstract idea of
“that thing that’s returned when a function is marked as throws
.”
And as such, we can use it to deal with asynchronous operations that require concrete types for parameters passed to their completion handlers.
So, while the shape of Result
has been implied by error handling since Swift 2
(and, indeed, quite a few developers have created their own versions of it in the intervening years),
it’s now officially added to the standard library in Swift 5 — primarily as a way to deal with asynchronous errors.
Which is undoubtedly better than passing the double-optional (Value?, Error?)
mess we saw earlier.
But didn’t we just get finished making the case that Result
tended to be overly verbose, nesty, and complex
when dealing with more than one error-capable call?
Yes we did.
And, in fact, this is even more of an issue in the async space
since flat
expects its transform to return synchronously.
So we can’t use it to compose asynchronous operations:
user Data(for: "jemmons") { user Result in
switch user Result {
case .success(let user):
fetch Avatar(for: user) { avatar Result in
switch avatar Result {
case .success(let avatar):
cloud Save(image: avatar) { save Result in
switch save Result {
case .success:
// All done!
case .failure(URLError.timed Out)
print("Operation timed out.")
…
}
}
case .failure(Avatar Error.invalid User Format):
print("User not recognized.")
…
}
}
case .failure(URLError.not Connected To Internet):
print("No internet detected.")
…
}
Awaiting the Future
In the near term, we just have to lump it. It’s better than the other alternatives native to the language, and chaining asynchronous calls isn’t as common as for synchronous calls.
But in the future, just as Swift used do/catch
syntax to define away Result
nesting problems in synchronous error handling, there are many proposals being considered to do the same for asynchronous errors (and asynchronous processing, generally).
The async/await proposal is one such animal. If adopted it would reduce the above to:
do {
let user = try await user Data(for: "jemmons")
let avatar = try await fetch Avatar(for: user)
try await cloud Save(image: avatar)
} catch Avatar Error.invalid User Format {
print("User not recognized.")
} catch URLError.timed Out {
print("Operation timed out.")
} catch URLError.not Connected To Internet {
print("No internet detected.")
} …
Which, holy moley! As much as I love Result
, I, for one, cannot wait for it to be made completely irrelevant by our glorious async/await overlords.
Meanwhile? Let us rejoice!
For we finally have a concrete Result
type in the standard library to light the way through these, the middle ages of async error handling in Swift.