How to efficiently migrate Result closures to Combine

Adam Wareing
4 min readNov 1, 2023

--

Migrating a code base from one framework to another can be a time expensive, risky, time consuming task. Particularly, when it involves touching multiple key areas of the app or is highly integrated in the codebase such an asynchronous framework such as Combine, PromiseKit, or RxSwift.

Note this article applies and is also relevant to third party SDK’s that you may use, which use Result completion handlers instead of Combine.

Say you have an API client that uses a standard completion handler

protocol APIClient {
func getUser(completion: @escaping (Result<User, Error>) -> Void)
}

We could implement it like the following

api.getUser() { [weak self] in
switch result {
case .success(let user):
self?.user = user
case .failure(let error):
self?.user = nil
}
}

But if we have already made the decision to migrate the codebase to Combine, then we would want to use it where we can and not rely on other aysnc patterns.

Migrating to Combine

In the happy case, you would change the Protocol from taking in a completion handler to returning a Publisher. Although, there may be multiple clients or callers to this function and you don’t want to refactor all of them at once, or release a breaking change to your SDK as your clients rely on the existing function and would need to update every client.

What else can you do?

You could add a new function inside of the SDK/ framework that instead returns a Publisher, or on the client side, you could adapt it from a completion handler to a Publisher. Let’s look at both but first, here is something that will help us convert it.

Introducing ResultPublisher

This is a custom Publisher that can turn a completion handler with a result into a Combine Publisher. If you want to learn how to create your own custom publishers, Swift by Sundell has a good write up on it.

You simply create the publisher, and pass the provided argument to as the completion parameter.

func getUser() -> AnyPublisher<User, Error> {
ResultPublisher { [weak self] in
self?.api.getUser(completion: $0)
}
}

You can get access to this Publisher by adding it to your app from the Gist here. You can also find the tests for it here.

Adding an additional function

As you can see, using ResultPublisher allows for a seamless migration where you need to do minimal work to convert it to a Publisher.

We can then add it as a seperate function on our APIClient protocol that clients can use and deprecate the old function if we desire to promote moving to Combine.

protocol APIClient {
@available(*, deprecated, message: "Prefer to use getUser() -> AnyPublisher instead")
func getUser(completion: @escaping (Result<User, Error>) -> Void)
func getUser() -> AnyPublisher<User, Error>
}

Converting at the call sign

An alternative way is instead of changing the service, we can be adaptive at the client layer to convert it to a Publisher to integrate with the rest of our code. Once all the clients have been converted, we could then replace the services definition to return a Publisher, and the clients have to perform a much more minimal update to their codebase as they are already using the Publisher variant.

ResultPublisher { [weak self] in
self?.api.validateUser(completion: $0)
}
.ignoreOutput(setOutputType: ProfilesUserProtocol?.self)
.replaceError(with: nil)
.assign(to: &$user)

How does ResultPublisher work?

Note that you don’t need to understand this, and can just use the linked file or copy and paste the code from the Gist. This is just if you’re curious.

We have a custom Publisher that takes in a function that allows us to provide the completion handler to the target function on instantiation of the Publisher.

public struct ResultPublisher<Success, Failure>: Publisher where Failure: Error {

public typealias Output = Success
public typealias Failure = Failure

public typealias ResultCallback = (Result<Success, Failure>) -> Void

let invocation: (@escaping ResultCallback) -> Void

public init(_ invocation: @escaping (@escaping ResultCallback) -> Void) {
self.invocation = invocation
}

public func receive<S>(subscriber: S) where S: Subscriber, Failure == S.Failure, Success == S.Input {
let subscription = ResultSubscription(
invocation: invocation,
subscriber: subscriber
)
subscriber.receive(subscription: subscription)
}
}

And we have a custom subscription that invokes the callback per subscriber and returns the result from the completion handler to the subscriber of the Publisher.

private extension ResultPublisher {

class ResultSubscription<S: Subscriber>: Subscription where S.Input == Success, S.Failure == Failure {

private let invocation: (@escaping ResultCallback) -> Void
private var subscriber: S?

init(invocation: @escaping (@escaping ResultCallback) -> Void, subscriber: S) {
self.invocation = invocation
self.subscriber = subscriber
invokeCallback()
}

func request(_ demand: Subscribers.Demand) {
// Adjust the demand in case you need to
}

func cancel() {
subscriber = nil
}

private func invokeCallback() {
guard let subscriber = subscriber else {
return
}

invocation { result in
switch result {
case .success(let value):
_ = subscriber.receive(value)
case .failure(let error):
subscriber.receive(completion: .failure(error))
}
}
}
}
}

Note that this doesn’t handle demand and won’t complete unless an error is received as we can never be sure how many times the completion handler would be used.

Wrapping up

There are many advantages to moving to Combine from completion handlers with Results. It’s far more readable, scalable, and enables it to do so many more functions.

While it can be daunting to move an entire code base, this Publisher can certainly help make it easier by moving one step at a time.

If you have any questions or comments please feel free to message me on X, my user name is @layoutGuides.

✌🏼

--

--