Throws Helps Readability
Swift’s error-related syntax calls attention to possible errors through try
and throws
. The do
/catch
syntax clearly separates the happy path (no errors) from the sad path (errors):
func exampleSyncUsageOfThrows() -> Bool {
do {
/* happy path */
let cookie = try ezbake()
eat(cookie)
return true
} catch {
/* sad path */
return false
}
}
Because throws
is “viral”, you’re forced to address it one way or another, even if it’s by deciding to flip your lid when you hit an error by using the exploding try!
.
No Async Syntax
Swift’s error-related syntax is great when every line of code executes one after another, synchronously. But it all goes to heck when you want to pause between steps to wait for an external event, like a timer finishing or a web server getting back to you with a response, or anything else happening asynchronously.
The Cocoa Completion Callback Pattern: Everything Is Optional
Let’s try that example again, only scheduling the cookie-baking for later, and then waiting for the cookie to cool before scarfing it:
func exampleAsyncDoesNotPlayNiceWithThrows(completion hadDinner: @escaping (Bool) -> Void) {
ezbakeTomorrow { cookie, error in
// hope you don't forget to check for an error first!
// also hope you like optional unwrapping
guard error == nil, let cookie = cookie else {
return hadDinner(false)
}
wait(tillCool: cookie) { coolCookie, error in
guard error == nil, let coolCookie = coolCookie else {
// dog snarfed cookie?
return hadDinner(false)
}
eat(coolCookie)
hadDinner(true)
}
}
}
This approach of calling a completion closure with parameters for both the desired result and the failure explanation all marked optional is common across Cocoa APIs as well as third-party code. Correctly unpacking those arguments relies heavily on convention. That is to say, it relies heavily on you being very careful not to shoot yourself in the foot.
Everything Is Optional?!
Because both the success value (cookie
) and the failure value (error
) might not be present, both end up being optionals. That means you end up with four cases to consider, of which two should probably never happen:
- Success!
cookie
but noerror
. This is unambiguous. - Failure.
error
but nocookie
. This is similarly unambiguous. - Kind of a failure, I think? Like, maybe? Both
error
ANDcookie
. If you’re following classic Cocoa style, this gets lumped in with the success case, so that a successful run could, before ARC, leaveerror
pointing at fabulously uninitialized data or scratch errors that didn’t happen. (As you might imagine, that convention gets messed up pretty often.) - Super-duper extra-failure. Neither
error
norcookie
. This is probably a bug in whatever’s giving you this output. But, alas, you still have to deal with it as a possibility.
Result: Better Async Support Through Types
Result
is a popular enumeration for cleaning this up. It looks something like:
enum Result<Value> {
case success(Value)
case failure(Error)
}
This addresses all the weirdness with the conventional approach:
- It explicitly has only two cases, so you don’t have to waste time considering the two “these are probably a bug” cases.
- You can’t mess up the convention and shoot yourself in the foot. You can only get access to either the failure or the success case by design.
- You can’t just ignore the error case and accidentally code for just the happy path.
case
exhaustiveness ensures the error is on your radar.
Just as do
/catch
lets you clearly separate handling a successful result from a failure, so does Result through switch
/case
:
func exampleAsyncLikesResult(completion hadDinner: @escaping (Bool) -> Void) {
ezbakeTomorrow { result in
switch result {
case let .success(cookie):
wait(tillCool: cookie) { result in
switch result {
// look ma, no optionals!
case let .success(coolCookie):
eat(coolCookie)
hadDinner(true)
case let .failure(_):
hadDinner(false)
}
}
case let .failure(_):
hadDinner(false)
}
}
}
Result: Sync, Async, It Just Works?
Result achieves the aims of do/catch
/throw
for async code. But it can also be used for sync code. This leads to competition between Result and throws for the synchronous case:
func exampleSyncUsageofResult() {
return
ezbake()
.map({ eat($0) })
.isSuccess
}
That’s…not so pretty. It would get even uglier if there was a sequence of possibly failing steps:
// this mess…
func exampleUglierSyncResult() {
return
open("some file")
.flatMap({ write("some text", to: $0) })
.map({ print("success!"); return $0 })
.flatMap({ close($0) })
.isSuccess
}
// …translates directly to this less-mess
func exampleSyncIsLessUglyWithTry() {
do {
let file = try open("some file")
let stillAFile = try write("some text", to: file)
print("success!")
try close(stillAFile)
return true
} catch {
return false
}
}
It’s kind of easy to lose the flow in all that syntax, plus it sounds like you have a funky verbal tic with the repeated map
and flatMap
. You also have to keep deciding between (and distracting your reader with the distinction between) map
and flatMap
.
Leave Result for Async, Switch to Throws When Sync
That suggests a rule of thumb: stick with throws
for synchronous code. Applying that even to mixed sync (within the body of completion callbacks) and async (did I mention completion callbacks?) code allows to play to the strengths of both throws
and Result
.
First, here’s a mechanical translation of the earlier exampleAsyncLikesResult
function:
func exampleMechanicallyBridgingBetweenAsyncAndSync(completion hadDinner: @escaping (Bool) -> Void) {
ezbakeTomorrow { result in
do {
let cookie = try result.unwrap()
wait(tillCool: cookie) { result in
do {
let coolCookie = try result.unwrap()
eat(coolCookie)
hadDinner(true)
} catch {
hadDinner(false)
}
}
} catch {
hadDinner(false)
}
}
}
Each completion accepts a Result
, but in working with it, it immediately returns to using the Swift try/throw/do/catch syntax.
try
has a try?
variant that allows to clean this up even more nicely. This is more like the code you’d likely write in the first place when using this style:
func exampleNicerBridgingBetweenAsyncAndSync(completion hadDinner: @escaping (Bool) -> Void) {
ezbakeTomorrow { result in
guard let cookie = try? result.unwrap()
else { return hadDinner(false) }
wait(tillCool: cookie) { result in
guard let coolCookie = try? result.unwrap
else { return hadDinner(false) }
eat(coolCookie)
hadDinner(true)
}
}
}
Bridging Helpers
This relies on some simple helper functions to bridge between Result
and throws
.
-
Result.unwrap() throws
goes fromResult
tothrows
: The caller of an async method that delivers aResult
can then useresult.unwrap()
to bridge back fromResult
into something you cantry
andcatch
.unwrap()
is a throwing function that throws if it’s.failure
and otherwise just returns its.success
value. We saw plenty of examples earlier. -
static Result.of(trying:)
goes fromthrows
toResult
: The implementation of async methods can useResult.of(trying:)
to wrap up the result of running a throwing closure as aResult
; this helper runs its throwing closure and stuffs any caught error in.failure
, and otherwise, wraps the result up in.success
.This is used to implement async functions delivering a result. Since the running example delivered a Boolean, you haven’t seen this used yet. Here’s a small example:
func youComplete(me completion: @escaping (Result<MissingPiece>) -> Void) {
doSomethingAsync { boxOfPieces: Result<PieceBox> in
let result = Result.of {
let box = try boxOfPieces.unwrap()
let piece = try findMissingPiece(in: box)
return piece
}
completion(result)
}
}
What these functions are called varies across Result
implementations (I’m eagerly awaiting the Swift version of what Promises/A+ delivered for JavaScript), but whatever your Result
calls them, use them! (And if they aren’t there, you can readily write your own.)
For a concrete example of implementing these, as well as the variation in names, check out antitypical/Result’s versions:
Result.unwrap() throws
: Result.dematerialize() throwsstatic Result.of(trying:)
: Result.init(attempt:)
That’s a Wrap
So that’s the bottom line:
- Use
Result
as your completion callback argument. - Within the completion body (and anywhere else you’re working synchronously), use
do
/catch
to work with potential errors.