Using Promises and Futures in Combine
Published on: February 10, 2020So 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:
- Get the current notification settings
- Transform the result to a
Bool
- 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.