Extending / Creating Combine Publishers the Right Way

Extending / Creating Combine Publishers the Right Way

I've been playing around with Combine for a while now and had some trouble navigating all the new protocols and types, especially when it came to extensions. In this article, I'll show you how to properly extend existing publishers and how to wrap them when creating your own custom publishers.

The Easy Case: Extending Basic Output Publishers

One of my favorite uses of Combine is how easily composable everything is. There's no need to create custom classes unless you really need to keep track of an external object:

let publisher = CurrentValueSubject<Int, Never>(0)

let evenSquaredPublisher = publisher.filter {
    $0 % 2 == 0
}.map {
    $0 * $0
}

let cancellable = evenSquaredPublisher.sink { print($0) }

Since all articles require a non-sense example, our example is going to be a publisher that streams the squared of even numbers. As you can see, it's trivial to develop this functionality, but what if we wanted to do this more than once around our code?

Surprisingly, due to limitations on the constrained extensions API, it took me a while to figure out the right way to abstract this under a function. My first idea, and what I think some people out there are doing, is to extend AnyPublisher and constrain it to the Int output used by the example:

extension AnyPublisher where Output == Int {
    func evenSquared() -> AnyPublisher<Output, Failure> {
        return filter {
            $0 % 2 == 0
        }.map {
            $0 * $0
        }.eraseToAnyPublisher()
    }
}

This however is not the right approach, because you'll now have to erase everyone who wants to use this method, and that's not why AnyPublisher exists:

let evenSquaredPublisher = publisher.eraseToAnyPublisher().evenSquared()

To extend publishers correctly in this case, we need to extend the main Publisher protocol:

extension Publisher where Output == Int {
    func evenSquared() -> AnyPublisher<Output, Failure> {
        return filter {
            $0 % 2 == 0
        }.map {
            $0 * $0
        }.eraseToAnyPublisher()
    }
}

let evenSquaredPublisher = publisher.evenSquared()

This is better, but we're still not done. Luckily for us (or not, as you'll see below), every one of these Combine operators return an actual concrete Publisher implementation, so with some help of the compiler we can return the correct result of the operation instead of AnyPublisher:

extension Publisher where Output == Int {
    func evenSquared() -> Publishers.Map<Publishers.Filter<Self>, Output> {
        return filter {
            $0 % 2 == 0
        }.map {
            $0 * $0
        }
    }
}

The signature of methods might let a little large because of this, but as far as I know, this is the correct way of approaching this problem, and you can make it better in this case with an alias:

typealias EvenSquaredPublisher<P: Publisher, T> = Publishers.Map<Publishers.Filter<P>, T>
extension Publisher where Output == Int {
    func evenSquared() -> EvenSquaredPublisher<Self, Output> {
        return filter {
            $0 % 2 == 0
        }.map {
            $0 * $0
        }
    }
}

So far this seems like nothing special, but don't worry, this is going to get weird pretty soon.

The Hardcore Case: Extending Publishers With Generic Outputs

There's one additional case I would like to show you, which is what prompted me to write this article. What if the output itself contains generics?

enum Condition<T> {
    case satisfied(T)
    case unsatisfied
}

let publisher = CurrentValueSubject<Condition<Int>, Never>(.satisfied(1))
let publisherB = CurrentValueSubject<Condition<Int>, Never>(.satisfied(2))

let combinedConditions = publisher.combineLatest(publisherB).map { tuple in
    guard case .satisfied(let pA) = tuple.0, case .satisfied(let pB) = tuple.1 else {
        return .unsatisfied
    }
    return .satisfied((pA, pB))
}

In this example, we're merging the contents of two conditions into a single condition that contains both publisher's values. But because the result here is Condition<T>, we can't use the constrained extension syntax anymore:

extension Publisher where Output == Condition
// Reference to generic type 'Condition' requires arguments in <...>

I personally find it really weird that this is not possible to do, and perhaps a point of improvement with Swift in the future. Still, there's a way you can achieve this. in this case, the correct approach is to instead constrain the method itself:

extension Publisher {
    func combineCondition<A, B, P: Publisher>(
        _ publisher: P
    ) -> Publishers.Map<Publishers.CombineLatest<Self, P>, Condition<(A, B)>>
    where Output == Condition<A>, P.Output == Condition<B>, Failure == P.Failure {
        return combineLatest(publisher).map { tuple in
            guard case .satisfied(let pA) = tuple.0, case .satisfied(let pB) = tuple.1 else {
                return .unsatisfied
            }
            return .satisfied((pA, pB))
        }
    }
}

As promised, things got weird pretty fast. Besides constraining the initial publisher's output to match the desired Condition type through an unconstrained generic A type in the method's signature, we also need to make sure that the merging publisher's Failure type matches the one that triggered the operation. That's a really big method!

To wrap it up, let me show you how to achieve the same thing when a custom type is used instead. In this case, we can do the same thing in the class's initializer:

final class CombinedConditionPublisher<A, B, F: Error>: Publisher {

    typealias Output = Condition<(A, B)>
    typealias Failure = F

    let combined: AnyPublisher<Condition<(A, B)>, F>

    init<PA: Publisher, PB: Publisher>(
        _ a: PA,
        _ b: PB
    )
    where PA.Failure == F, PB.Failure == F, PA.Output == Condition<A>, PB.Output == Condition<B>
    {
        combined = a.combineLatest(b).map { tuple in
            guard case .satisfied(let pA) = tuple.0, case .satisfied(let pB) = tuple.1 else {
                return .unsatisfied
            }
            return .satisfied((pA, pB))
        }.eraseToAnyPublisher()
    }

    func receive<S>(subscriber: S) where S : Subscriber, Failure == S.Failure, Output == S.Input {
        combined.receive(subscriber: subscriber)
    }
}

extension Publisher {
    func combineCondition<A, B, P: Publisher>(
        _ publisher: P
    ) -> CombinedConditionPublisher<A, B, Failure>
    where Output == Condition<A>, P.Output == Condition<B>, Failure == P.Failure {
        return CombinedConditionPublisher(self, publisher)
    }
}

This case is a little more complex though, because we now need to use AnyPublisher to cover the fact that we cannot constrain PA and PB in the class's main definition. Still, the functionality will be the same.

While it's unfortunate that the signatures are enormous, things will look perfect on the call site. As mentioned before, as weird as it looks, this seems to be exactly how Combine implements some of its operators.

let publisher = CurrentValueSubject<Condition<Int>, Never>(.satisfied(1))
let publisherB = CurrentValueSubject<Condition<Int>, Never>(.satisfied(2))

let combined = publisher.combineCondition(publisherB)