Using Promises and Futures in Combine

Published on: February 10, 2020

So far in my Combine series I have mostly focussed on showing you how to use Combine using its built-in mechanisms. I've shown you how Combine's publishers and subscribers work, how you can use Combine for networking, to drive UI updates and how you can transform a Combine publisher's output. Knowing how to do all this with Combine is fantastic, but your knowledge is also still somewhat limited. For example, I haven't shown you at all how you can take an asynchronous operation in your existing code, and expose its result using Combine. Luckily, that is exactly what I'm going to cover in this (for now) final post in my Combine series.

In this post I will cover the following topics:

  • Understanding what Promises and Futures are
  • Wrapping an existing asynchronous operation in a Future

By the end of this post, you will be able to take virtually any async operation in your codebase and you will know how to expose it to combine using a Future.

Understanding what Promises and Futures are

The concept of Promises and Futures isn't unique to Combine or even iOS. The Javascript community has been working with Promises for a while now. We've had implementations of Promises in iOS for a while too. I even wrote a post about improving async code with PromiseKit in 2015. In Combine, we didn't get a Promises API that's identical to Javascript and PromiseKit's implementations. Instead, we got an API that is based on Futures, which is also a common concept in this kind of working area. The implementation of Combine's Futures and Promises is quite similar to the one that you'd find in Javascript on a surface level. A function can return an object that will eventually resolve with a value or an error. Sounds familiar?

In Combine, a Future is implemented as a Publisher that only emits a single value, or an error. If you examine Combine's documentation for Future, you'll find that Future conforms to Publisher. This means that you can map over Futures and apply other transformations just like you can with any other publisher.

So how does a Future work? And how do we use it in code? Let's look at a simple example:

func createFuture() -> Future<Int, Never> {
  return Future { promise in
    promise(.success(42))
  }
}

createFuture().sink(receiveValue: { value in
  print(value)
})

The createFuture function shown above returns a Future object. This object will either emit a single Int, or it fails with an error of Never. In other words, this Future can't fail; we know it will always produce an Int. This is the exact same principle as having a publisher in Combine with Never as its error. In the body of createFuture, an instance of a Future is created. The initializer for Future takes a closure. In this closure, we can perform asynchronous work, or in other words, the work we're wrapping in the Future. The closure passed to Future's initializer takes a single argument. This argument is a Promise. A Promise in Combine is a typealias for a closure that takes a Result as its single argument. When we're done performing our asynchronous work, we must invoke the promise with the result of the work done. In this case, the promise is immediately fulfilled by calling it with a result of .success(42), which means that the single value that's published by the Future is 42.

The result of a Future is retrieved in the exact same way that you would get values from a publisher. You can subscribe to it using sink, assign or a custom subscriber if you decided that you need one. The way a Future generates its output is quite different from other publishers that Combine offers out of the box. Typically, a publisher in Combine will not begin producing values until a subscriber is attached to it. A Future immediately begins executing as soon it's created. Try running the following code in a playground to see what I mean:

func createFuture() -> Future<Int, Never> {
  return Future { promise in
    print("Closure executed")
    promise(.success(42))
  }
}

let future = createFuture()
// prints "Closure executed"

In addition to immediately executing the closure supplied to the Future's initializer, a Future will only run this closure once. In other words, subscribing to the same Future multiple times will yield the same result every time you subscribe. It's important to understand this, especially if you come from an Rx background and you consider a Future similar to a Single. They have some similarities but their behavior is different.

The following is a list of some key rules to keep in mind when using Futures in Combine:

  • A Future will begin executing immediately when you create it.
  • A Future will only run its supplied closure once.
  • Subscribing to the same Future multiple times will yield in the same result being returned.
  • A Future in Combine serves a similar purpose as RxSwift's Single but they behave differently.

If you want your Future to act more like Rx's Single by having it defer its execution until it receives a subscriber, and having the work execute every time you subscribe you can wrap your Future in a Deferred publisher. Let's expand the previous example a bit to demonstrate this:

func createFuture() -> AnyPublisher<Int, Never> {
  return Deferred {
    Future { promise in
      print("Closure executed")
      promise(.success(42))
    }
  }.eraseToAnyPublisher()
}

let future = createFuture()  // nothing happens yet

let sub1 = future.sink(receiveValue: { value in 
  print("sub1: \(value)")
}) // the Future executes because it has a subscriber

let sub2 = future.sink(receiveValue: { value in 
  print("sub2: \(value)")
}) // the Future executes again because it received another subscriber

The Deferred publisher's initializer takes a closure. We're expected to return a publisher from this closure. In this case we return a Future. The Deferred publisher runs its closure every time it receives a subscriber. This means that a new Future is created every time we subscribe to the Deferred publisher. So the Future still runs only once and executes immediately when it's created, but we defer the creation of the Future to a later time.

Note that I erased the Deferred publisher to AnyPublisher. The only reason I did this is so I have a clean return type for createFuture.

The example I just showed you isn't particularly useful on its own but it does a decent job of explaining the basics of a Future. Let's move on to doing something a little bit more interesting, shall we? In the next section I will not use the Deferred publisher. I will leave it up to you to decide whether you want to wrap Futures in Deferred or not. In my experience, whether or not you should defer creation of your Futures is a case-by-case decision that depends on what your Future does and whether it makes sense to defer its creation in the context of your application.

Wrapping an existing asynchronous operation in a Future

Now that you understand the basics of how a Future is used, let's look at using it in a meaningful way. I already mentioned that Futures shine when you use them to wrap an asynchronous operation in order to make it a publisher. So what would that look like in practice? Well, let's look at an example!

extension UNUserNotificationCenter {
  func getNotificationSettings() -> Future<UNNotificationSettings, Never> {
    return Future { promise in
      self.getNotificationSettings { settings in
        promise(.success(settings))
      }
    }
  }
}

This code snippet defines an extension on the UNUserNotificationCenter object that is used to manage notifications on Apple platforms. The extension includes a single function that returns a Future<UNNotificationSettings, Never>. In the function body, a Future is created and returned. The interesting bit is in the closure that is passed to the Future initializer. In that closure, the regular, completion handler based version of getNotificationSettings is called on the current UNUserNotificationCenter instance. Inside of the completion handler, the promise closure is called with a successful result that includes the current notification settings. So what do we win with an extension like this where we wrap an existing async operation in a Future? Let's look at some code that doesn't use this Future based extension:

UNUserNotificationCenter.current().getNotificationSettings { settings in
  switch settings.authorizationStatus {
  case .denied:
    DispatchQueue.main.async {
      // update UI to point user to settings
    }
  case .notDetermined:
    UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { result, error in
      if result == true && error == nil {
        // We have notification permissions
      } else {
        DispatchQueue.main.async {
          // Something went wrong / we don't have permission.
          // update UI to point user to settings
        }
      }
    }
  default:
    // assume permissions are fine and proceed
    break
  }
}

In this basic example, we check the current notification permissions, and we update the UI based on the result. You might apply different abstractions in your code, but writing something like the above isn't entirely uncommon. And while it's not horrible, you can see the rightward drift that's occurring in the notDetermined case when we request notification permissions. Let's see how Futures can help to improve this code. First, I want to show a new extension on UNUserNotificationCenter that I'll be using in the refactored example:

extension UNUserNotificationCenter {
  func requestAuthorization(options: UNAuthorizationOptions) -> Future<Bool, Error> {
    return Future { promise in
      self.requestAuthorization(options: options) { result, error in
        if let error = error {
          promise(.failure(error))
        } else {
          promise(.success(result))
        }
      }
    }
  }
}

This second extension on UNUserNotificationCenter adds a new flavor of requestAuthorization(options:) that returns a Future that tells us whether we successfully received notification permissions from a user. It's pretty similar to the extension I showed you earlier in this section. Let's look at the refactored flow that I showed you earlier where we checked the current notification permissions, asked for notification permissions if needed and updated the UI accordingly:

UNUserNotificationCenter.current().getNotificationSettings()
  .flatMap({ settings -> AnyPublisher<Bool, Never> in
    switch settings.authorizationStatus {
    case .denied:
      return Just(false)
        .eraseToAnyPublisher()
    case .notDetermined:
      return UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge])
        .replaceError(with: false)
        .eraseToAnyPublisher()
    default:
      return Just(true)
        .eraseToAnyPublisher()
    }
  })
  .sink(receiveValue: { hasPermissions in
    if hasPermissions == false {
      DispatchQueue.main.async {
        // point user to settings
      }
    }
  })

There is a lot going on in this refactored code but it can be boiled down to three steps:

  1. Get the current notification settings
  2. Transform the result to a Bool
  3. Update the UI based on whether we have permissions

The most interesting step in this code is step two. I used a flatMap to grab the current settings and transform the result into a new publisher based on the current authorization status. If the current status is denied, we have asked for permissions before and we don't have notification permissions. This means that we should return false because we don't have permission and can't ask again. To do this I return a Just(false) publisher. Note that this publisher must be erased to AnyPublisher to hide its real type. The reason for this becomes clear when you look at the notDetermined case.

If the app hasn't asked for permissions before, the user is asked for notification permissions using the requestAuthorization(options:) that's defined in the extension I showed you earlier. Since this method returns a Future that can fail, we replace any errors with false. This might not be the best solution for every app, but it's fine for my purposes in this example. When you replace a publisher's error with replaceError(with:), the resulting publisher's Error is Never since any errors from publishers up the chain are now replaced with a default value and never end up at the subscriber. And since we end up with a Publishers.ReplaceError when doing this, we should again erase to AnyPublisher to ensure that both the notDetermined case and the denied case return the same type of publisher.

The default case should speak for itself. If permissions aren't denied and also not notDetermined, we assume that we have notification permissions so we return Just(true), again erased to AnyPublisher.

When we subscribe to the result of the flatMap, we subscribe to an AnyPublisher<Bool, Never> in this case. This means that we can now subscribe with sink and check the Bool that's passed to receiveValue to determine how and if we should update the UI.

This code is a little bit more complicated to understand at first, especially because we need to flatMap so we can ask for notification permissions if needed and because we need to erase to AnyPublisher in every case. At the same time, this code is less likely to grow much more complicated, and it won't have a lot of rightward drift. The traditional example I showed you before is typically more likely to grow more complicated and drift rightward over time.

In summary

In this week's post, you learned that Futures in Combine are really just Publishers that are guaranteed to emit a single value or an error. You saw that a Future can be returned from a function and that a Future's initializer takes a closure where all the work is performed. This closure itself receives a Future.Promise which is another closure. You must call the Promise to fulfill it and you pass it the result of the work that's done. Because this textual explanation isn't the clearest, I went on to show you a basic example of what using a Future looks like.

You also learned an extremely important detail of how Futures work. Namely that they begin executing the closure you supply immediately when the Future is created, and this work is only performed once. This means that subscribing to the same Future multiple times will yield the same result every time. This makes Futures a good fit for running one-off asynchronous operations.

I went on to demonstrate these principles in an example by wrapping some UNUserNotificationCenter functionality in Future objects which allowed us to integrate this functionality nicely with Combine, result in code that is often more modular, readable and easier to reason about. This is especially true for larger, more complicated codebases.

If you have feedback or questions about this post or any other content on my blog, don't hesitate to shoot me a tweet. I love hearing from you.

Categories

Combine

Subscribe to my newsletter