I've recently started work to replace the usage of promises in a code base with Combine. While Combine is powerful, like all reactive frameworks it's also extremely complex, especially for the rather simple use cases we currently have, such as:
- Making network responses synchronously and simply available to callers. That is, promises offer the ability to inspect the value or error returned on the promise itself, without having to attach any additional API. This is most useful for testing, as it makes it possible to inspect everything directly. I've implemented a
Future
wrapper that does this, but it's... inelegant.
ResponseFuture
public final class RequestFuture<Output, Failure: Error>: Publisher {
public typealias Output = Output
public typealias Failure = Failure
/// `DataResponse<Response, Failure>` received by `self`.
public var response: DataResponse<Output, Failure>? { subject.value }
private var subject = CurrentValueSubject<DataResponse<Output, Failure>?, Never>(nil)
private var cancellables: [AnyCancellable] = []
private let future: Future<DataResponse<Output, Failure>, Never>
deinit {
NSLog("deinit \(Self.self)")
}
/// Creates an instance that invokes the provided `Promise` closure when a response value is provided.
///
/// - Parameter attemptToFulfill: `Promise` closure used to receive the response value.
public init(_ attemptToFulfill: @escaping (_ closure: @escaping Future<DataResponse<Output, Failure>, Never>.Promise) -> Void) {
future = Future(attemptToFulfill)
cancellables.append(future.map(Optional.init).subscribe(subject))
}
public func receive<S>(subscriber: S) where S: Subscriber, RequestFuture.Failure == S.Failure, RequestFuture.Output == S.Input {
future.map(\.result)
.setFailureType(to: Failure.self)
.flatMap { result in Future { promise in promise(result) } }
.receive(subscriber: subscriber)
}
func map<NewResponse, NewFailure: Error>(transform: @escaping (_ originalResponse: DataResponse<Output, Failure>) -> DataResponse<NewResponse, NewFailure>) -> RequestFuture<NewResponse, NewFailure> {
RequestFuture<NewResponse, NewFailure> { promise in
var cancellable: AnyCancellable?
cancellable = self.future.map(transform).sink {
promise(.success($0)); _ = cancellable; cancellable = nil
}
}
}
}
- Allow the transform and chaining of async work without having to deal with different types. With promises, aside from the generic type of your values and error, everything stays within the promise type. This is very convenient, especially in the previous case where I may want to directly inspect the response values from the final (or any) case. There doesn't seem to be way to accomplish this in Combine, not only because of general API complexity, but because of lifetime issues in the operator chains built up. As you can see in the special
map
implementation I have onRequestFuture
, I have to keep the transform chain alive myself, which is really gross. - Even in the case where I'm able to use Combine for the above case, there doesn't seem to be a way to reach back into a chain of publishers and get any of the specific publisher values, they're all either within the complex resulting type or erased under
AnyPublisher
. For example, I can create chains of myRequestFuture
type, but then I lose the ability to inspect them afterward:
firstRequest().flatMap { value in
secondRequest()
}.flatMap { secondValue in
thirdRequest()
}
- My thought is to create a custom
then
on myRequestFuture
that just keeps returningRequestFuture
s but that's both complex and likely to hit the same lifetime issues I dealt with for the much simplermap
operation. Without this I lose the ability to inspect the full response value during testing.
So, does anyone have any guidance for simple Combine usage that can meet these goals? I've already spent quite a bit of time trying to bend it to my will, but I can't afford to spend much more time on such seemingly simple usage.