Weekly Swift articles, podcasts and tips by John Sundell.

Under the hood of Futures and Promises in Swift

Remastered on 23 Jan 2020

Asynchronous programming is arguably one of the most difficult parts of building modern apps. Whether it’s handling background tasks such as network requests, performing heavy operations in parallel across multiple threads, or executing code with a delay — asynchronous code can often be hard to both write and debug.

Because of this, many different abstractions have been created around asynchronous programming, to attempt to make such code easier to understand and reason about. What’s true for most of those solutions is that they offer ways to more neatly structure nested asynchronous calls, rather than having to construct a ”pyramid of doom” of increasingly nested closures.

This week, let’s take a look at one such solution — Futures and Promises — not only on the surface level, but also “under the hood”, to see how they actually work by building an implementation completely from scratch.

A promise about the future

When introduced to the concept of Futures and Promises, the first thing that’s very common to ask is "What’s actually the difference between a Future and a Promise?". The easiest way to think about it, in my opinion, is like this:

If we use the above definition, Futures and Promises become two sides of the same coin. A promise gets constructed, then returned as a future, where it can be used to extract information at a later point.

So what does that look like in code? Let’s take a look at an asynchronous operation, in which we load data for a User model over the network, transform it into a instance, and then finally save it to a local database. Using the "old fashioned way", based on closures, doing so would look something like this:

class UserLoader {
    typealias Handler = (Result<User, Error>) -> Void
    
    ...
    
    func loadUser(withID id: User.ID, completionHandler: @escaping Handler) {
        let url = urlForLoadingUser(withID: id)
        
        let task = urlSession.dataTask(with: url) { [weak self] data, _, error in
            if let error = error {
                completionHandler(.failure(error))
            } else {
                do {
                    let decoder = JSONDecoder()
                    let user = try decoder.decode(User.self, from: data ?? Data())
                    
                    self?.database.save(user) {
                        completionHandler(.success(user))
                    }
                } catch {
                    completionHandler(.failure(error))
                }
            }
        }
        
        task.resume()
    }
}

Like the above example illustrates, even with a quite simple (and very common) set of asynchronous operations, we end up with multiple levels of nested code when using completion handler closures. Let’s compare that to what the above example would look like when using Futures and Promises instead:

class UserLoader {
    ...

    func loadUser(withID id: User.ID) -> Future<User> {
        urlSession
            .request(url: urlForLoadingUser(withID: id))
            .decoded()
            .saved(in: database)
    }
}

The beauty of abstractions like Futures and Promises is that they let us group all of our nested asynchronous operations into a single one, which in turn enables us to handle the end result using just a single closure — like this:

let userLoader = UserLoader()

userLoader.loadUser(withID: userID).observe { result in
    // Handle result
}

At first glance, the above might seem a bit like magic (where did all of our code go?), so let’s dive deeper and take a look at how it’s all implemented under the hood.

Like most things in programming, there are many different ways to implement Futures and Promises. In this article we’ll build a simple (but still fully functional) implementation, and at the end there will be links to a few popular open source libraries that offer a lot more functionality.

Looking into the future

Let’s start by taking a closer look at a Future implementation, which is the type of value that we’ll publicly return from our asynchronous operations. It offers a read only way to observe whenever a value is assigned to it and maintains a list of observation callbacks, like this:

class Future<Value> {
    typealias Result = Swift.Result<Value, Error>
    
    fileprivate var result: Result? {
        // Observe whenever a result is assigned, and report it:
        didSet { result.map(report) }
    }
    private var callbacks = [(Result) -> Void]()
    
    func observe(using callback: @escaping (Result) -> Void) {
        // If a result has already been set, call the callback directly:
        if let result = result {
            return callback(result)
        }
        
        callbacks.append(callback)
    }
    
    private func report(result: Result) {
        callbacks.forEach { $0(result) }
        callbacks = []
    }
}

Next, let’s look at the flip side of the coin. Our Promise type will be a subclass of Future that adds APIs for resolving and rejecting it. Resolving a promise results in the future being successfully completed with a value, while rejecting it results in an error. Here’s what that implementation looks like:

class Promise<Value>: Future<Value> {
    init(value: Value? = nil) {
        super.init()
        
        // If the value was already known at the time the promise
        // was constructed, we can report it directly:
        result = value.map(Result.success)
    }
    
    func resolve(with value: Value) {
        result = .success(value)
    }
    
    func reject(with error: Error) {
        result = .failure(error)
    }
}

Looking at the above two types, the basic implementation of Futures and Promises is actually quite simple. However, a lot of the "magic" involved in using them comes from extensions that adds ways to chain and transform futures — enabling us to construct chains of operations, just like we did in the earlier UserLoader example.

Making a promise

Before we proceed with adding any chaining and transformation APIs though, we can already construct the first part of our user loading chain — performing a network request using URLSession. A common practice when building reusable abstractions in general is to provide convenience APIs on top of Foundation and the Swift standard library, so that’s what we’ll do here too — by extending URLSession with a Future/Promise-based request(url:) API:

extension URLSession {
    func request(url: URL) -> Future<Data> {
        // We'll start by constructing a Promise, that will later be
        // returned as a Future:
        let promise = Promise<Data>()
        
        // Perform a data task, just like we normally would:
        let task = dataTask(with: url) { data, _, error in
            // Reject or resolve the promise, depending on the result:
            if let error = error {
                promise.reject(with: error)
            } else {
                promise.resolve(with: data ?? Data())
            }
        }
        
        task.resume()
        
        return promise
    }
}

With the above in place, we can now easily perform a standard GET network request like this:

URLSession.shared.request(url: url).observe { result in
    // Handle result
}

When performing just a single operation, there’s not that much of a difference between using a closure-based API and a Futures/Promises-based one, but that drastically changes when we start chaining multiple operations together.

Chaining

Chaining involves providing a closure that, given a successful result, returns another future for a new value. That will enable us to take the result from one operation, pass it onto another one, and then return the final result. Let’s take a look:

extension Future {
    func chained<T>(
        using closure: @escaping (Value) throws -> Future<T>
    ) -> Future<T> {
        // We'll start by constructing a "wrapper" promise that will be
        // returned from this method:
        let promise = Promise<T>()
        
        // Observe the current future:
        observe { result in
            switch result {
            case .success(let value):
                do {
                    // Attempt to construct a new future using the value
                    // returned from the first one:
                    let future = try closure(value)
                    
                    // Observe the "nested" future, and once it
                    // completes, resolve/reject the "wrapper" future:
                    future.observe { result in
                        switch result {
                        case .success(let value):
                            promise.resolve(with: value)
                        case .failure(let error):
                            promise.reject(with: error)
                        }
                    }
                } catch {
                    promise.reject(with: error)
                }
            case .failure(let error):
                promise.reject(with: error)
            }
        }
        
        return promise
    }
}

Using the above, we can now continue to build higher-level extensions and utilities, for example one that lets us easily save the result of any Future for a Saveable value into a database:

extension Future where Value: Saveable {
    func saved(in database: Database) -> Future<Value> {
        chained { value in
            let promise = Promise<Value>()
            
            database.save(value) {
                promise.resolve(with: value)
            }
            
            return promise
        }
    }
}

Now we’re starting to tap into the true potential of Futures and Promises, and we can see how easily extendable they are, as we can add all sorts of convenience APIs for various values and operations by using different generic constraints on the Future type.

Transforms

While chaining provides a really powerful way to sequentially perform multiple asynchronous operations, sometimes we just want to apply a simple synchronous transform to a value — so let’s also add an API for doing just that. We’ll call it transformed(), and just like our chained() method from before, we’ll add it using an extension on Future, like this:

extension Future {
    func transformed<T>(
        with closure: @escaping (Value) throws -> T
    ) -> Future<T> {
         chained { value in
             try Promise(value: closure(value))
        }
    }
}

As we can see above, a transform is really just a synchronous version of a chaining operation, and since its value is directly computed — we can simply pass that value into a new Promise, which is then returned. Using our new transform API, we can now add support for transforming a future for Data into a future for a Decodable type, which we’ll be able to use anywhere we’d like to decode any downloaded data into a model:

extension Future where Value == Data {
    func decoded<T: Decodable>(
        as type: T.Type = T.self,
        using decoder: JSONDecoder = .init()
    ) -> Future<T> {
        transformed { data in
            try decoder.decode(T.self, from: data)
        }
    }
}

Really cool! Using the power of Swift’s type system, which enables us to extend Future with generic constraints for any type or protocol, we can continue to build up a rich library of extensions that’ll let us instantly perform all sorts of transforms and chaining operations that our project needs.

Putting everything together

We now have all of the parts needed to upgrade our UserLoader to use Futures and Promises instead of nested closures. Let’s start by defining each of the required operations on a separate line, so that it’ll be a bit easier to see what’s going on within each step:

class UserLoader {
    ...

    func loadUser(withID id: User.ID) -> Future<User> {
        let url = urlForLoadingUser(withID: id)
        
        // Request the URL, returning data:
        let requestFuture = urlSession.request(url: url)
        
        // Transform the loaded data into a User model:
        let decodedFuture = requestFuture.decoded(as: User.self)
        
        // Save the user in our database:
        let savedFuture = decodedFuture.saved(in: database)
        
        // Return the last future, as it marks the end of our chain:
        return savedFuture
    }
}

That’s already a lot more compact and readable than our closure-based implementation from before, but we can of course also do what we did in an earlier example and chain all of our Future calls together — which also lets us take advantage of Swift’s type inference capabilities:

class UserLoader {
    ...

    func loadUser(withID id: User.ID) -> Future<User> {
        urlSession
            .request(url: urlForLoadingUser(withID: id))
            .decoded()
            .saved(in: database)
    }
}

Apart from the fact that the above method returns a Future, it’s almost hard to tell whether its synchronous or asynchronous, which might seem like a bad thing at first — but it really enables us to reason about our asynchronous code as simpler sequences of operations, which often makes it easier to both read and write.

Conclusion

Futures and Promises can be incredibly powerful when writing asynchronous code, especially when we need to chain multiple operations and transforms together. It almost enables us to write asynchronous code as if it was synchronous, which can really improve readability and make it easier to move things around if needed.

However — like when using most abstractions — we are essentially ”burying” a fair amount of our complexity, by letting our Future type and its associated extensions do most of the heavy lifting. So while a urlSession.request(url:) API looks really nice from the outside, it can sometimes be hard to understand and debug what’s happening on the inside.

My advice to anyone using Futures and Promises is to try to keep all chains as short and simple as possible, and to remember that good documentation and a solid suite of unit tests can really help us avoid a lot of headaches and tricky debugging in the future (no pun intended).

Here are some popular open source frameworks for using Futures and Promises in Swift:

You can also find the implementation built in this article on GitHub here.

Do you have questions, feedback or comments? I'd love to hear from you! 👍 Feel free to contact me on Twitter @johnsundell, or via email.

Thanks for reading 🚀