A few weeks ago, Matt Gallagher wrote about Swift’s error model, in comparison with the Result
type. He puts it quite succinctly:
Swift’s error handling has a major limitation: it can only be used to pass an error to an enclosing catch scope or the previous function on the stack.
If you want to handle errors across asynchronous boundaries or store value/error results for later processing, then Swift error handling won’t help.
This is the clearest explanation of the problem with Swift’s error handling that I’ve seen. Having a dedicated Result
type, Matt argues, would solve the async problem and the error storage problem. John McCall, on the mailing list, wrote about why the team didn’t include Result
:
We considered it, had some specifics worked out, and then decided to put it on hold. Part of our reasoning was that it seemed more like an implementation detail of the async / CPS-conversion features we’d like to provide than an independently valuable feature, given that we don’t want to encourage people to write library interfaces using functional-style error handling instead of
throws
.
The Swift team considers Result
not useful enough to include in the standard library. The only reason to add it, they argue, is as an implementation detail of async features that they would add in the future.
Let’s examine what those potential async features might look like, design them in the same way that the error handling was designed, and see how these new features could fill all the gaps of the current error handling model.
If we use C#’s async/await model that I discussed in the last post, a Swifty version might look something like this:
async func getCurrentUsersFollowers() throws -> [User] {
let user = try await APIClient.getCurrentUser()
let followers = try await APIClient.getFollowers(for: user)
return followers
}
This is the closest I can get it to how the error model works. In the same way that Swift’s throwing functions don’t return a Result
, this function doesn’t return an Async<[User]>
or any kind of wrapped type (e.g, Task<[User]>
, à la C#), but rather the raw value itself, and it uses the async
decorator on the function (like the throws
decorator) to express that the function must be awaited upon.
func updateUI() {
do {
self.followers = try await getCurrentUsersFollowers()
self.tableView.reloadData()
} catch let error as APIError {
self.presentError(error)
} catch error {
print(error)
}
}
To use getCurrentUsersFollowers()
, we need a function that returns Void
, so that it won’t need to be marked as async
. This is the equivalent of wrapping function marked throws
in do
/catch
blocks.
Lastly, we need some way to define a new async task. The Swift error model uses the throw
keyword opposite the return
keyword to handle the distinction between success and failure. I’m not sure how exactly Swift syntax would define a new async task, but for simplicity’s sake, let’s imagine it as a closure that runs on some background thread. When the closure is complete, it returns a value, and that’s the result of the async function:
async func getCurrentUser() {
let url = buildURLForCurrentUser()
return await {
let data = Data(contentsOf: url)
let user = //parse JSON, construct User
return user
}
}
This is some super fake syntax I made up. The goal is to stay as true to the model of Swift’s error handling as possible. Specifically, I want to prevent users from constructing a type that would represent the async task, in the same way you don’t create a Result
when using the error handling constructs.
Since we don’t have a type to work with, we can’t do anything other than perform a sequential series of asynchronous, potentially erroring tasks, such as in the getCurrentUsersFollowers()
function. I’m happy to grant that sequential, erroring tasks are 90% of the kind of work that we do with asynchronicity, in the same way that passing errors up the stack is 90% of what we need error handling for. But the remaining 10% of stuff is important too. As Larry Wall says, “make the easy jobs easy, without making the hard jobs impossible”. Swift’s error handling and this new, fake async syntax both make the hard jobs literally inexpressible, requiring programmers to pull in their own abstractions, like Result
and Promise
, to get the job done.
Promise.all
is probably the second most common use case for async tasks: given an array of async tasks, create one async task that completes when every subtask has completed. If we wanted to implement Promise.all
with this new async syntax we’d need some combination of an array of functions that are async, a concurrent dispatch queue, a dispatch group, and a bunch of messy synchronization. On the other hand, its implementation on Promise
almost trivial, complete with thread-safety and type-safety.
Other, more infrequently-used behaviors, like recover
, delay
, or race
, are similarly easy with Promises, but tougher with this kind of async feature. Here’s another one that I found useful, Promise.zip
:
public static func zip<T, U>(first: Promise<T>, second: Promise<U>) -> Promise<(T, U)> {
return Promise<(T, U)>(work: { fulfill, reject in
let resolver: (Any) -> () = { _ in
if let firstValue = first.value, secondValue = second.value {
fulfill((firstValue, secondValue))
}
}
first.then(resolver, reject)
second.then(resolver, reject)
})
}
Where Promise.all
requires that every promise have the same type, Promise.zip
(and any higher arity versions of it you might want) combines two promises with two different types (T
and U
) into one promise of type (T, U)
as a tuple. This way, if you need two things to finish that return different types, you can maintain their types while still waiting for them to both complete. Promise.all
is used frequently, and there will almost definitely be some kind of facility for it built into Swift’s async features (even if they’re hard-coded into the syntax). Promise.zip
, on the other hand, would hardly ever be used, so the odds of it being added to the standard library are nil. But if one of our projects eventually did need it, it’s nice to know it’s easy enough to add ourselves.
As easy as it is to write helpers on a fully-fledged abstraction like Promise
, it’s tricky and complicated when the feature is built into the language’s syntax. Without the manipulability of fully-blown types that represent these constructs, we can’t build useful one-off functions like zip
.
Consider if optionality were built into the language the way that errors are. You could never extend the Optional type. There’d be no more using flatMap
to unwrap. There’d be no way to turn a tuple with two optionals into one optional tuple with two non-optional values (the equivalent of our Promise.zip
above). My fake async
/await
syntax and the very real Swift 2 error handling are hard-coded into the language, and they both prevent this kind of extensibility and flexibilty that you get with Optional
.
This extensibility is often limited and even removed with excuses of safety and soundness; we’ve certainly seen those arguments made for removing features like mutability, metaprogramming, and reflection. However, in many of these cases, these features are restricted without any increase in type safety or the other guarantees that we subscribe to Swift for. In those cases, the reasoning for why we can’t have nice things falls apart. As I wrote a few weeks ago:
Being able to conform my own types SequenceType shows that the language trusts me to make my own useful abstractions (with no loss of safety or strictness!) on the same level as its own standard library.
Perhaps I’m wrong about how the syntax will look. I truly hope that’s the case. As I’ve shown, this syntax is purposely almost crippled in order to make two points: first, that without the proper supporting abstractions, async
/await
develops holes very similar to the current error handling model. The Swift team wants to fill in one of those holes (async) with more syntax, but that merely kicks the can down the road.
Second, the goal of this fictional async
/await
syntax was to design it so that it’s limited in similar ways to the error handling model; given these limitations, if async
/await
sans abstractions falls short, then doesn’t our current error model also deserve an overhaul?
I don’t know if the ship has sailed for redesigning the way that Swift error handling works; it probably has. The async ship is still in the docks, to stretch the metaphor, and I’d like to make sure the language that we’ll be writing code in for the next 20 years has as solid of a foundation as possible.