Creating Combine-compatible versions of async/await-based APIs
Discover page available: CombineA challenge that many developers face as they maintain various code bases over time is how to neatly connect different frameworks and APIs in a way that properly adheres to the conventions of each technology involved.
For example, as teams around the world are starting to adopt Swift 5.5’s async/await
-powered concurrency system, we’ll likely find ourselves in situations where we need to create versions of our async
-marked APIs that are compatible with other asynchronous programming techniques — such as Combine.
While we’ve already taken a look at how Combine relates to concurrency APIs like async sequences and streams, and how we can make it possible to call async
-marked functions within a Combine pipeline — in this article, let’s explore how we could make it easy to create Combine-based variants of any async
API, regardless of whether it was defined by us, by Apple, or as part of a third-party dependency.
Async futures
Let’s say that an app that we’re working on contains the following ModelLoader
, which can be used to load any Decodable
model over the network. It performs its work through an async
function that looks like this:
class ModelLoader<Model: Decodable> {
...
func loadModel(from url: URL) async throws -> Model {
...
}
}
Now let’s say that we’d also like to create a Combine-based version of the above loadModel
API, for example in order to be able to call it within specific parts of our code base that might’ve been written in a more reactive style using the Combine framework.
We could of course choose to write that sort of compatibility code specifically for our ModelLoader
type, but since this is a general problem that we’re likely to encounter multiple times when working with Combine-based code, let’s instead create a more generic solution that we’ll be able to easily reuse across our code base.
Since we’re dealing with async
functions that either return a single value, or throw an error, let’s use Combine’s Future
publisher to wrap those calls. That publisher type was specifically built for these kinds of use cases, since it gives us a closure that can be used to report a single Result
back to the framework.
So let’s go ahead and extend the Future
type with a convenience initializer that makes it possible to initialize an instance with an async
closure:
extension Future where Failure == Error {
convenience init(operation: @escaping () async throws -> Output) {
self.init { promise in
Task {
do {
let output = try await operation()
promise(.success(output))
} catch {
promise(.failure(error))
}
}
}
}
}
For more information on how Combine’s Future
type works, check out “Using Combine’s futures and subjects”.
The power of creating an abstraction like that, which isn’t tied to any specific use case, is that we’ll now be able to apply it to any async
API that we want to make Combine-compatible. All it takes is a few lines of code that calls the API that we’re looking to bridge within a closure that’s passed to our new Future
initializer — like this:
extension ModelLoader {
func modelPublisher(for url: URL) -> Future<Model, Error> {
Future {
try await self.loadModel(from: url)
}
}
}
Neat! Note how we could’ve chosen to give that Combine-based version the same loadModel
name that our async
-powered one has (since Swift supports method overloading). However, in this case, it might be a good idea to clearly separate the two, which is why the above new API has a name that explicitly includes the word “Publisher”.
Reactive async sequences
Async sequences and streams are perhaps the closest that the Swift standard library has ever come to adopting reactive programming, which in turn makes those APIs behave very similarly to Combine — in that they enable us to emit values over time.
In fact, in the article “Async sequences, streams, and Combine”, we took a look at how Combine publishers can even be directly converted into async sequences using their values
property — but what if we wanted to go the other way, and convert an async sequence (or stream) into a publisher?
Continuing with the ModelLoader
example from before, let’s say that our loader class also offers the following API, which lets us create an AsyncThrowingStream
that emits a series of models loaded from an array of URLs:
class ModelLoader<Model: Decodable> {
...
func loadModels(from urls: [URL]) -> AsyncThrowingStream<Model, Error> {
...
}
}
For an example of a concrete implementation of an API just like the one above, check out the aforementioned article “Async sequences, streams, and Combine”.
Just like before, rather than rushing into writing code that specifically converts the above loadModels
API into a Combine publisher, let’s instead try to come up with a generic abstraction that we’ll be able to reuse whenever we want to write similar bridging code elsewhere within our project.
This time, we’ll extend Combine’s PassthroughSubject
type, which gives us complete control over when its values are emitted, as well as when and how it should terminate. However, we’re not going to model this API as a convenience initializer, since we want to make it clear that calling this API will, in fact, make the created subject start emitting values right away. So let’s make it a static factory method instead — like this:
extension PassthroughSubject where Failure == Error {
static func emittingValues<T: AsyncSequence>(
from sequence: T
) -> Self where T.Element == Output {
let subject = Self()
Task {
do {
for try await value in sequence {
subject.send(value)
}
subject.send(completion: .finished)
} catch {
subject.send(completion: .failure(error))
}
}
return subject
}
}
For more on how static factory methods might differ from initializers in terms of API design, check out “Initializers in Swift”
With the above in place, we can now wrap our async stream-based loadModels
API almost as easily as our previous async
-marked one — the only extra step that’s required in this case is to type-erase our PassthroughSubject
instance into an AnyPublisher
, to prevent any other code from being able to send new values to our subject:
extension ModelLoader {
func modelPublisher(for urls: [URL]) -> AnyPublisher<Model, Error> {
let subject = PassthroughSubject.emittingValues(
from: loadModels(from: urls)
)
return subject.eraseToAnyPublisher()
}
}
Just like that, we’ve now created two convenience APIs that make it very straightforward for us to make code using Swift’s concurrency system backward compatible with Combine — which should prove to be incredibly convenient when working with a code base that uses Combine to some extent.
Conclusion
Even though Swift does now have a built-in concurrency system that covers much of the same ground as Combine does, I think both of those two technologies will continue to be incredibly useful for years to come — so the more we can create smooth bridges between them, the better.
While some developers will likely opt to completely rewrite their Combine-based code using Swift concurrency, the good news is that we don’t have to do that. With just a few convenience APIs in place, we can make it trivial to pass data and events between those two technologies, which in turn will let us keep using our Combine-based code, even as we start adopting async/await
and the rest of Swift’s concurrency system.
I hope that you found this article useful. If you did, or if you have any feedback, questions, or comments, then please let me know — either on Twitter or via email.
Thanks for reading!