Wrapping callback-based APIs as publishers unlocks Combine's operator chain: retry,
replaceError, flatMap, and friends. Two building blocks cover it: Future for a single
async result and PassthroughSubject for a stream of values over time.
Future, Wrapped in Deferred
Future turns a completion handler into a one-shot publisher. Its trap: it is eager, so the
work runs the moment you create it, not when someone subscribes. Wrap it in Deferred to
make it run on subscription.
func authorize() -> AnyPublisher<Bool, Error> {
Deferred {
Future { handler in
self.store.requestAuthorization(toShare: [.workout], read: [.hr]) { success, error in
if let error = error {
handler(.failure(error))
} else {
handler(.success(success))
}
}
}
}.eraseToAnyPublisher()
}
The payoff is at the call site: health.authorize().retry(3).replaceError(with: false)
composes retry and fallback logic that the raw callback API cannot express.
PassthroughSubject for Streams
Future completes after its first value. For values arriving over time (locations, heart
rate samples), create a PassthroughSubject and push into it with send.
func heartRate() -> AnyPublisher<[Double], Error> {
let subject = PassthroughSubject<[Double], Error>()
let query = HKAnchoredObjectQuery(
type: HKQuantityType.heartRate,
predicate: nil, anchor: nil, limit: HKObjectQueryNoLimit
) { _, newSamples, _, _, error in
if let error = error {
subject.send(completion: .failure(error))
} else {
let samples = newSamples as? [HKQuantitySample] ?? []
subject.send(samples.compactMap { $0.quantity.doubleValue(for: .bpm()) })
}
}
return subject.handleEvents(
receiveSubscription: { _ in self.store.execute(query) },
receiveCancel: { self.store.stop(query) }
).eraseToAnyPublisher()
}
The handleEvents operator ties the underlying long-running query to the subscription
lifecycle: start it on subscribe, stop it on cancel. Without that, the query runs forever
even after everyone stopped listening.
Composing the Two
flatMap chains the one-shot into the stream, so authorization gates the data feed.
health.authorize()
.retry(3)
.flatMap { authorized in
authorized ? health.heartRate().eraseToAnyPublisher()
: Empty().eraseToAnyPublisher()
}
.replaceError(with: [])
.sink { print($0) }
.store(in: &cancellables)