API Design — Deriving Future

In this article, we will explain what asynchronous operations are, why they are useful, and how their APIs are typically designed. Specifically, we will look at an API for performing network requests. Step by step, we will improve this API, first by introducing the Result<T> type, and later on, by abstracting the asynchronous operation itself, by deriving the Future<T> type. We will also show how these two types could be implemented and extended with useful operations. In the end, this will lead to code that is more composable, easier to reason about as well as to maintain.

Asynchronous operations

When building applications interacting with a user, a goal is to keep the application responsive at all time. It is thus important to not perform long-running operations that will block the UI. This is one reason why many APIs used in UI intensive applications are asynchronous. Being asynchronous means that the caller will not block while executing an operation using the API, but instead, the API will call the user back when the result is available.

A common reason for an API to be asynchronous is that it models access to external services. These services often end up accessing some hardware. As most hardware have much longer latencies than the main CPU, it does make sense to use the CPU for other work while awaiting the result. External services are also often unreliable by nature, which means that they might fail. So it is common that many asynchronous APIs need to be able to report those failures.

A common asynchronous operation is a network request. On Apple platforms, we typically use URLSession's task-based APIs such as:

extension URLSession {
func dataTask(with url: URL, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask
}

The signature of the method hints that it is asynchronous as the expected Data result is not immediately returned. Instead, a completionHandler closure is passed to the API. This closure will be called as soon as a result is available or when the operation failed for some reason.

So let us focus in on the completionHandler. We will ignore the less used URLResponse to simplify the API a bit. What does it tell us?

completionHandler: (Data?, Error?) -> Void

We would expect to get some data back or if something went wrong an error. But as a consumer of this API we actually have to handle four cases:

switch (data, error) {
case (data?, nil): // Received data
case (nil, error?): // Received error
case (data?, error?): // Received both data and an error?
case (nil, nil): // Received neither data nor an error?
}

By reading the documentation for dataTask() we can see that the last two cases should never occur, and it should be safe to ignore them. But it would be great if we did not have to rely on documentation to express this.

Introducing the Result enum

The completionHandler introduced above originates from Objective-C, and hence is limited to what can be expressed in Objective-C. Swift, on the other hand, has a more powerful type-system making it easier to express many of our ideas in a more type-safe way. One of Swift's constructs are enums with associated values. This is a really powerful construct that would let us express the either Data or Error as:

enum Result {
case success(Data)
case failure(Error)
}

Further, by using Swift generics, Result can be generalized to work with any success type Value instead of just Data:

enum Result<Value> {
case success(Value)
case failure(Error)
}

With Result<T> at our disposal, we can now simplify our network API. When at it, we will also make the API more focused on the data result than the data task itself:

func data(at url: URL, completion: @escaping (Result<Data>) -> Void) {
dataTask(with: url) { data, _, error in
if let error = error {
completion(.failure(error))
} else {
completion(.success(data!))
}
}.resume()
}

Transforming results

It is not that common to work directly with an instance of Data. Typically, we would instead transform the data into more specialized types. In most applications, this would likely be into some JSON container, or into types being deserialized from JSON. Let us investigate how we can transform Data into JSON.

For this article, we will use the framework Lift to work with JSON. Lift wraps JSON using the Jar container type. It comes with an initializer accepting raw JSON data:

extension Jar {
init(json data: Data) throws
}

So how do we go about transforming a Result<Data> into a Result<Jar>. Firstly, we need a way to extract the success value:

extension Result {
func getValue() throws -> Value {
switch self {
case .success(let value): return value
case .failure(let error): throw error
}
}
}

And given a value or a thrown error, we need a way to put them back into a Result<T>. This is like running getValue() in reverse:

extension Result {
init(getValue: () throws -> Value) {
do {
self = .success(try getValue())
} catch {
self = .failure(error)
}
}
}

Combining the two, allow us to first extract the data from a Result<Data>, then transform it to JSON using Jar(json:), and then finally construct a Result<Jar> with the resulting JSON:

let jarResult = Result<Jar> {
try Jar(json: dataResult.getValue())
}

This is called a map(), the operation of transforming a value inside a box into another one:

extension Result {
func map<O>(_ transform: (Value) throws -> O) -> Result<O> {
return Result<O> { try transform(getValue()) }
}
}

The implementation of map() is a great example of how we can compose new methods from more general ones. The map() function is a very useful function that you will find in other box like types as well, such as Swift's optional and container types. Using map(), our new JSON method now becomes:

func json(at url: URL, completion: @escaping (Result<Jar>) -> Void) {
return data(at: url) { result in
completion(result.map { try Jar(json: $0) })
}
}

Here we can see how we chained two operations, one asynchronous and one synchronous, both of which might fail. Thanks to Result<T> and map(), we could focus on the "happy cases" and avoid writing explicit error handling.

Introducing the Future type

We now have two asynchronous APIs, one returning Data and the other Jar. As for the asynchronous part of those APIs, the completion function itself, the only thing that differs between the two is the type of their results, the T in Result<T>. It would be nice if we could encapsulate this completion closure in a type and make it generic:

struct Future<Value> {
let onResult: (@escaping (Result<Value>) -> Void) -> Void
}

This would let us rewrite data(at:completion:) as:

func data(at url: URL) -> Future<Data> {
return Future { completion in
self.data(at: url, completion: completion)
}
}

To listen on a future’s result, we would just call onResult() and pass a completion handler:

let dataAtURL = session.data(at: url)
...
dataAtURL.onResult { result in
...
}

This is really powerful. We have abstracted the idea of an asynchronous operation and encapsulated it into a type. Instances of this type could be passed around, this without any need to know the origin of the operation nor having to drag along any of its dependencies.

Having a type representing asynchronous operations also means we can extend it with useful functionality. One of those is map(), as Future<T> similar to Result<T> also acts as a box wrapping a value:

func map<O>(_ transform: @escaping (Value) throws -> O) -> Future<O> {
return Future<O> { completion in
self.onResult { result in
completion(result.map(transform))
}
}
}

Our JSON method can now be simplified to:

func json(at url: URL) -> Future<Jar> {
return data(at: url).map { try Jar(json: $0) }
}

It is now so trivial that it might not even be worth adding a convenience method for it.

Improving the implementation

The current implementation of Future<T>, although elegantly simple, has some issues. One is, that you have to call onResult() on the future to start it. If no one ever calls onResult(), its asynchronous operation will never be performed. But the expectation is that it should always perform, even though no one is interested in the result. Some APIs make this explicit by letting the completion handler be optional. You would expect, that e.g. an animation would run even though you passed a nil completion handler.

A related problem is that nothing stops us from calling onResult() more than once. This would currently result in a new asynchronous operation being kicked off for each call to onResult(). This problem did not exist when we passed a completion handler, where at most one could be passed.

Given the above, it makes sense to model Future<T> to always execute its work, even though there potentially are no ones interested in the result. We might attempt to restrict the number of listeners to at most one. Potentially, we could assert or complete with an error if calling onResult() a second time. But in the context of the user, there might be no way of knowing if somebody is already listening on the future or not. And even if it was, it is not obvious what the user should do with that information. So it also makes sense to allow multiple listeners, but still only execute the future once.

So let us update Future<T>'s internals by adding an initializer that immediately calls back the provided onResult closure. The previous onResult member now becomes a function instead where we add received completions to a completions array. As the future now has internal state, we also update it to be a class instead of a struct:

final class Future<Value> {
var completions: [(Result<Value>) -> Void] = []

init(onResult: @escaping (@escaping (Result<Value>) -> Void) -> Void) {
onResult { result in
self.completions.forEach { $0(result) }
}
}

func onResult(_ completion: @escaping (Result<Value>) -> Void) {
completions.append(completion)
}
}

This solves our two original issues but introduces a new one. What if the future has already been completed at the time onResult() is called? That would mean that its completion callback will never be called. This will be an issue even if we call onResult() immediately after creating the future, as the future could potentially complete even before leaving the initializer:

Future<Int> { c in c(.success(1)) }.onResult { result in
// Will never be called as `c` was called before `onResult()` was called.
}

The solution is to keep the result around in the case someone calls onResult() after the future has completed. And once we have a result we do not need to keep the completions around any longer. So the state of a future is now either pending with an array of completions or completed with a result:

final class Future<Value> {
enum State {
case completed(Result<Value>)
case pending([(Result<Value>) -> Void])
}

var state: State
}

The initializer now becomes:

init(onResult: @escaping (@escaping (Result<Value>) -> Void) -> Void) {
state = .pending([])
onResult { result in
guard case .pending(let completions) = self.state else { return }
completions.forEach { $0(result) }
self.state = .completed(result)
}
}

And onResult() is updated to immediately call the completion back if the future has already been completed:

func onResult(completion: @escaping (Result<Value>) -> Void) {
switch state {
case .completed(let result):
completion(result)
case .pending(var completions):
completions.append(completion)
state = .pending(completions)
}
}

Chaining futures

Adding map() to Future<T> demonstrated how we can construct a new future from a previous one, in this case by transforming the internal value. A perhaps even more powerful transform is to be able to chain two asynchronous operations. In this case, we want to construct a new future representing one operation performed after another one, where the second operation has access to the result from the first. So we would like a version of map() where the value is not just transformed into a new one using the (A) -> B transform. Instead, we would like to transform the value into the second operation using a (A) -> Future<B> transform. This is called a flatMap(), and similar to map(), can be found on Swift optional and containers as well. It would also make sense to add flatMap() to our Result<T> type.

To better see how map() and flatMap() are basically the same for Optional, Array, Result and Future, we could look at the signatures of their transformation functions:

| type      | map()    | flatMap()        |
|-----------|----------|------------------|
| A? | (A) -> B | (A) -> B? |
| [A] | (A) -> B | (A) -> [B] |
| Result<A> | (A) -> B | (A) -> Result<B> |
| Future<A> | (A) -> B | (A) -> Future<B> |

Given the function signature of flatMap(), even with the addition of letting the transform to throw errors, the implementation for Future<T> is still quite straight-forward:

func flatMap<O>(_ transform: @escaping (Value) throws -> Future<O>) -> Future<O> {
return Future<O> { completion in
self.onResult { result in
do {
try transform(result.getValue()).onResult(completion)
} catch {
completion(.failure(error))
}
}
}
}

Focus on the “happy flow”

As the transformations passed to both map() and flatMap() are called with a success value, we can conclude they will only be called if such a value is available. This normally means that all operations preceding them were successful as well. It is quite typical when working with futures that you focus on this "happy flow". Failures are typically just implicitly forwarded and are explicitly handled only where required, and then normally at the end of a sequence of operations.

Where onResult() requires us to handle both failures and successes, it is often more convenient to focus on one or the other. This can easily be improved by adding an onValue() helper:

func onValue(_ callback: @escaping (Value) throws -> Void) -> Future {
return map { value in
try callback(value)
return value
}
}

Similarly, we can add an onError() helper.

Bringing it all together we can now build quite complex compositions, such as fetching a user’s friends:

json(at: userURL).flatMap { jar in
let user: User = try jar^
return json(at: user.friendsURL)
}.map { jar in
let friends: [Friend] = try jar^
return friends
}.onValue { friends in
// Everything was successful
}.onError { error in
// Something failed
}

If you wonder what the try jar^ is all about, it is just how Lift converts the JSON contained inside a Jar into model values.

In this example, you can see how futures makes it easy to chain several transformations after each other. If you would try to do this with the original APIs, using completion callbacks and without Result<T>, the code would soon be really messy and hard to read and maintain.

In the above example, it might not be obvious at first how much could fail. Both requests could fail, transforming data to JSON could fail, and constructing our model values could fail as well. If all goes well, we end up in onValue()'s callback. If something fails, we will end up in onError()'s callback.

Summary

We started out discussing what asynchronous operations are, why they are useful in UI application, and how they typically are modeled with completion callbacks. We then introduced the Result<T> type to better model asynchronous results. After updating our APIs to use Result<T>, we recognized that most completion handlers look the same but for the type T returned, hence we defined the Future<T> type to encapsulate asynchronous operations.

We discovered that futures carry a state of being completed or not. This state resulted in the implementation being a bit more tricky then perhaps first anticipated. Finally, we introduced the map() and flatMap() transforms and showed how we could chain futures together and mainly focus on the "happy flow".

If you want to learn more about futures and try them out yourself, download the open sourced framework Flow. Here, you can also see how a production implementation might look like. There is also a playground available, where you can see the code introduced in this article all come together.

In an upcoming article, we will see how we can make futures even more powerful and convenient to work with.

Like what you read? Give Måns Bernhardt a round of applause.

From a quick cheer to a standing ovation, clap to show how much you enjoyed this story.