The Promise of a Better Future: Eliminating Swift Callback Hell with a 100-line Futures library

I’m going to start with what should be a non-controversial statement - asynchronous programming is hard! Despite all of our best efforts, developing mobile applications that are increasingly feature-full has meant we’ve had to write significantly complex and asynchronous code. Keeping track of how your app works is a challenge to say the least.

It’s the kind of challenge that often starts with the idea that it’ll be easy, and never ends up so. It’s the kind of challenge that we as mobile app developers deal with day in and day out. After all, much of the Genius mobile apps need to do some amount of async work in addition to managing local state. With the recent inclusion of a Result type into the forthcoming Swift 5 standard library, we felt like now is the best time to talk about how we use asynchronous result types to make our code more legible and easier to reason about.

How This Got Started

A few months back we began looking to ways to improve the music player in our iOS app. What started out as a seemingly simple task quickly became one that was increasingly difficult to follow and debug. One particularly thorny issue we ran into time and again was determining, just by a method’s call site, whether or not the method was synchronous and safe to continue afterwards.

Take for example this seemingly innocuous method call:

self.musicPlayer.play()

Looking at this method, can you tell whether or not the method executes asynchronously? Is it safe to update our player UI on the next line of code? Other than the fact this is in a blog post about async programming, there aren't a lot of context clues to look at, and the answer is more complicated than yes or no. Depending on what services a user has connected, the current player state and various other factors, the method may or may not be asynchronous. The lesson learned was that it wasn’t always safe for us to update our interface immediately. We embarrassingly ran into this as we watched an early prototype confuse itself as to whether or not the player was playing, despite an abundance of music coming from the phone’s speakers. Something was clearly amiss.

So we reached into Swift’s bag of tricks and changed the method. Our first solution was simple enough - we used Swift’s trailing closure syntax for the rare chances that we would need to delay user interface updates.

self.musicPlayer.play() { player, error in
    guard let player = player else {
        return
    }
    self.updateUI(player: player)
}

But there are a number of problems with this approach. First of all without any labels on the play method, it’s not terrifically clear what the context of the closure is meant to be. There’s also the issue of a single completion handler with optional error and player values that need to be unwrapped or possibly ignored. Of course, with the aforementioned inclusion of a Result type in Swift 5, we can make this code a little nicer to work with:

self.musicPlayer.play() { result in
    switch result {	
      ...
    }
}

This does away with the issues of unwrapping, and, thanks to exhaustive switches in Swift, makes us at least acknowledge the existence of an error, but there is still the question of context and meaning of our code. The crux of the issue is that the code doesn't describe the code the way we describe the code. When walking through the code we say “tell the music player to play”, then “update the UI”.

The word then gave us an idea.

Introducing Futures

Genius uses a lot of JavaScript on all of its platforms. It is pervasive on the web, but even in our apps we utilize React Native on certain views. So JavaScript’s solution to async callback hell, the Promise, is not foreign to us.

We wondered what they might look like in Swift with the constraints of its strong type system and its weaknesses with regards to generics. In order to get started prototyping what would become our Futures library, we opened up an Xcode playground and starting describing what we wanted the code to look like.

We knew the basic shape of the class would need to look like

public class Future<T: Any, E: Error> {
    typealias Value = T
    typealias Error = E
}

Note: We consider using T & E for our generic Value and Error types a bit too concise to be readable, so we immediately added typealiases to make things a bit nicer to read. We are shadowing the builtin Swift Error type with our own typealias, which isn’t ideal but works well enough in this situation.

We understood that the basics of our class would need a “then” method and a “catch” method (borrowing the terms of art from Javascript). Those methods should either save the closure passed into them for when the Future resolves, or immediately invoke the closure if the Future has been resolved.

private var _value: Value?
private var _observers = [((Value) -> Void)]()

public func then(_ block: @escaping ((Value) -> Void)) {
    if let value = self._value {
	    block(value)
    } else {
        self._observers.append(block)
    }
}

private var _error: Error?
private var _errorObservers = [((Error) -> Void)]()

public func `catch`(_ block: @escaping ((Error) -> Void)) {
    if let error = self._error {
        block(error)
    } else {
        self._errorObservers.append(block)
    }
}

So now we have simple & usable “then” and “error” methods that do what we described above. The only thing that remains are methods to handle resolving the future. Those methods - resolve & reject - are simple enough.

public func resolve(_ value: Value) {
    self._value = value
    self._observers.forEach { $0(value) }
    self._observers = []
}

public func reject(_ error: Error) {
    self._error = error
    self._errorObservers.forEach { $0(value) }
    self._errorObservers = []
}

And there you have it, our entire first draft of a Future in only a handful of lines of code. What did we get for so little code?

1. Clean separation of value & error handlers

2. Type safe success & error handling

3. Late binding of “then” and “catch” blocks

4. Asynchronous code that reads like how we describe it. Do this then do that.

That's not to say that this simple Future is without it's problems. They're definitely not as nice as JavaScript’s Promises - there's no chaining via returns, no exception promotion to error blocks, no collections of futures - but what we noticed is that we wouldn't want any of those features if it wasn't for the fact that we had the core working.

Stumbling Blocks

As with any code hastily sketched into an Xcode playground, there were some issues we ran into almost immediately.

Types, Types Everywhere

func getSong(id: Int) -> Future<Song, APIError> {
    let future = Future<Song, APIError>()
    ...
    return future
}

Do you see the problem here? In an ideal world we shouldn't need to write out the type of our Future twice, but the Swift type inference system can't guess the return type by the context clues we give it. Thanks to Swift's trailing closure syntax, we can make an unlabeled initializer that will make our code nicer to write.

public init(_ block: ((Future<Value, Error>) -> Void)) {
    block(self)
}

The initializer takes a block and immediately passes itself into the block. The closure never escapes the initializer, which makes reasoning about the lifecycle simpler. Using this initializer lets us write:

func getSong(id: Int) -> Future<Song, APIError> {
    return Future { future in
        ...
        future.resolve(value: song)
    }
}

It was at this point where we realized that having resolve and reject available on the Future itself was the wrong direction. Not only does it mean that anyone with a reference to the future can resolve it, but it was also too easy to hold onto references to the entire Future long past when we're done with it. We solved this problem by moving the resolve and reject methods into a Resolver subtype and adjusting the initializer above accordingly

public class Future<T: Any, E: Error> {
    ...
    public class Resolver {
        ...
        public func resolve(_ value: Value) {}
        public func reject(_ error: Error) {}
    }
    
    public init(_ block: @escaping ((Future<T, E>.Resolver) -> Void)) {}
}

...

func getSong(id: Int) -> Future<Song, APIError> {
    return Future { resolver in
        ...
        resolver.resolve(value: song)
    }
}

Empty Promises

There are times promises might not return a value or might never error. The Swift type system can help us describe and enforce these cases. Swift has an empty “no value” type called Void. But how do we create a new Void?

resolver.resolve(value: Void())
resolver.resolve(value: ())

As it turns out you can create Voids any time you want, using the Void initializer or with `()`, but both of those look fairly out of place in the code we want to write.

Note: Void is actually a typealias of the () empty tuple, a value in the type system that exists but has no members, no size and no value. See for yourself, it’s a neat trick:

https://github.com/apple/swift/blob/44f0b0519fcf57b15c665bf4646a6727a8ee9771/stdlib/public/core/Policy.swift#L64

Now, by using Swift's excellent protocol extensions & conditional conformances, we can create a way to bypass the out of place Void initializer:

public extension Future.Resolver where T == Void {
    public func resolve() {
        self.resolve(value: ())
    }
}

So for any future who’s Value type is Void, we can simply call resolver.resolve(), which is a lot closer to how we think about our code.

Similarly, in cases where we know there will be no error thrown during the Future's lifetime, we can go one step further and use the type system to guarantee that the error block will never be called.

enum NoError: Swift.Error {}

Here, our NoError is an enum with zero cases, and is considered uninhabitable. (Note: This is similar to how Swift's builtin Never type is defined). Swift gives us zero ways to create a new NoError, and, without a value to pass into the error block, we know any Future whose Error type is NoError cannot call:

resolver.reject(error: ???)

But Why Another Library?

There are already a number of excellent, full-featured Promise libraries available to iOS developers right now. So why, you may ask, did we spend the time to write our own?

  1. We took a look at the existing projects before we began and decided they didn’t match what we were looking for. We knew that this was an important enough project that we wanted to control how and where we rolled it out into our codebase. Above all else what we wanted was simple, easy to reason about, and easy to use.
  2. Writing our own library gave us a chance to think and talk critically about a problem we were facing and tackle it in a way that best suits the way we work. We had the opportunity to custom tailor a solution to match our needs and it isn’t often as iOS developers that we get the chance to fundamentally rethink how we structure code.
  3. This was an interesting problem to solve and gave us a chance to grow. We got a chance to write interesting generic code with tricky type system constraints that we don’t normally get to write in a mixed Objective-C and Swift UIKit codebase.
  4. Writing code and sharing it with everyone is a good thing even if it isn’t the right solution for the next viewer. Perhaps they, like us, can take inspiration from the code and challenge themselves to write code they enjoy more.

It is important to note that we decided early on that we didn’t want this to be a massive project that would prevent us from shipping meaningful updates to our users. To borrow from Gall’s law, we knew that anything complex enough to work with all of our use cases would have to start simply. In the end it was that dedication to simplicity that ended up making this project a success.

Wrapping Up

As we continued adding Futures to our code we continued finding ways to make it nicer to work with. We have been working towards filling in the features we missed from Javascript Promises, all while keeping a keen eye on the original goal of the project - making our code more legible and easier to reason about.

At the end of the day we traded a small amount of runtime performance for improved code legibility. In doing so we found a handful of bugs caused by bad assumptions about our code.

In the end we felt it was worth it.

If you’re interested in seeing the code for yourself, it is available here: https://github.com/genius/future

Go forth, and may your futures be bright.

Share on: ,

Next post

How We Almost Gave Up a 50%+ Increase in Conversion Rate