
Swift: Can a Promise Be Called More Than Once in a Future?
Let’s dive into my recent experience working with Futures, Promises, and Combine.
Background
We have a legacy app with all kinds of code, even old Objective-C classes. I stumbled upon this when converting an old, massive ViewController into a new SwiftUI screen backed by a ViewModel.
Everything went smoothly until I stumbled upon a code that fetched data from another Obj-C class, which wasn’t our API class. Currently, our API calls are wrapped with Combine publishers inside ViewModels. Our API class contains a function that wraps the result into AnyPublisher which we can use in our data fetching pipelines in ViewModels. However, this class didn’t have such a wrapper, so I needed to create one.
Data fetching through completion
The Objective-C method looked like this when translated into Swift.
class FakeApi {
static let operationalSessionStatus: String = "Operational"
func loadDataWithStatus(
completion: @escaping (Bool, (any Error)?) -> Void
) -> String {
let status = FakeApi.operationalSessionStatus
DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: {
completion(true, nil)
})
return status
}
}
Notice that it not only returns data asynchronously through the completion handler, but also synchronously returns a status String
.
When the loadDataWithStatus
was called in a ViewController, ViewController checked the status returned by the function right away and conditionally canceled the loading and the request:
let fakeApi = FakeApi()
func loadDataWithCompletion() {
let status = fakeApi.loadDataWithStatus(
completion: { result, error in
print(result)
}
)
if status != FakeApi.operationalSessionStatus {
// Cancel the loading and data fetching
}
}
Solving the problem
Wrapping a function with a completion handler into a function that returns AnyPublisher can be achieved using a Future
publisher.
By looking into the documentation, we see that Future
is:
- A publisher that eventually produces a single value and then finishes or fails.
Let’s create func loadDataPublisher() -> AnyPublisher<Bool?, Error>
, which returns a Future
, inside of which we will call our API through loadDataWithStatus
. Since loadDataWithStatus
returns the status String
, we have to handle it inside of the Future
:
func loadDataPublisher() -> AnyPublisher<Bool?, Error> {
return Future { [weak self] promise in
guard let self else { return }
let status = self.loadDataWithStatus { result, error in
if let error {
promise(.failure(error))
} else {
promise(.success(result))
}
}
if status == FakeApi.operationalSessionStatus {
promise(.success(false))
}
}
.eraseToAnyPublisher()
}
Promise could get called twice
I immediately noticed something that didn’t add up. The function combines synchronous and asynchronous logic and can return a promise
in both. The question that comes to mind is: what if the function returns a promise in the status check if statement, and then also in the completion of the request? The documentation states that a Future produces a single value and then finishes. I therefore assumed it would ignore any additional promises.
However, I was curious and therefore tried to discuss this with ChatGPT. It contradicted my assumption and suggested that calling a promise multiple times could be problematic. That left me only one course of action - try it out.
Returning multiple promises
So what actually happens when we try to return multiple promises?
To answer this question, I built a simplified version of a publisher - testLoadDataPublisher
. In it, two promises are executed synchronously, along with a DispatchQueue.main.asyncAfter
block that simulates an asynchronous operation. I also added print statements for debugging purposes.
func testLoadDataPublisher() -> AnyPublisher<Bool, Error> {
return Future { promise in
print("Future execution start")
DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: {
print("Future execution in a completion")
promise(.success(false))
})
promise(.success(true))
print("Future execution after promise")
promise(.success(false))
}
.eraseToAnyPublisher()
}
We can trigger the execution of this publisher easily through:
func testLoadData() {
testCancellable = testLoadDataPublisher()
.sink { completion in
print("Sink completion:\(completion)")
} receiveValue: { value in
print("Receive value:\(value)")
}
}
let fakeApi = FakeApi()
Button {
fakeApi.testLoadData()
} label: {
Text("Execute Fake publishers")
}
Results
Let’s analyze the console logs:
First, we can see that all print statements inside the Future were executed. This means the code inside the Future continues running even after the first promise is called and delivered.
Second, only one value was received from a publisher - Receive value:true
, after which the sink
received a completion event.
Summary
This means that Apple’s documentation is correct. It could be more explicit, but a Future
does what it says. However, since we can see that those other print statements have been executed, we have to be extra careful when dealing with multiple promises. If you don’t include additional logic inside the Future and only return multiple promises, you’re probably safe — though as with any undocumented behavior, future iOS or Xcode updates could change this.
Next steps
If you want to be on the safe side, or handle some extra logic that should not run after the first promise has been called, you could add a nested function that handles calling a promise with the result, and any additional logic you may need to execute only once before the first promise gets executed:
func testNestedLoadDataPublisher() -> AnyPublisher<Bool, Error> {
return Future { promise in
var isCompleted = false
func complete(_ result: Result<Bool, Error>) {
guard !isCompleted else { return }
print("Future execution - nested - complete")
isCompleted = true
promise(result)
}
print("Future execution - nested - start")
DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: {
print("Future execution - nested - in a completion")
complete(.success(false))
})
complete(.success(true))
print("Future execution - nested - after promise")
complete(.success(false))
}
.eraseToAnyPublisher()
}
func testNestedLoadData() {
testNestedCancellable = testNestedLoadDataPublisher()
.sink { completion in
print("Sink completion:\(completion)")
} receiveValue: { value in
print("Receive value:\(value)")
}
}
Console log:
Future execution - nested - start
Future execution - nested - complete
Future execution - nested - after promise
Receive value:true
Sink completion:finished
Future execution - nested - in a completion
Conclusion
A Future
delivers its promise only once. However, if you include additional logic inside a Future
, use extra caution to ensure it doesn’t run multiple times unintentionally.