Simplifying Combine Usage: A Promise-like API?

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 on RequestFuture, 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 my RequestFuture 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 my RequestFuture that just keeps returning RequestFutures but that's both complex and likely to hit the same lifetime issues I dealt with for the much simpler map 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.

7 Likes
Terms of Service

Privacy Policy

Cookie Policy