This is Understanding Combine, written by Matt Neuburg. Corrections and suggestions are greatly appreciated (you can comment here). So are donations; please consider keeping me going by funding this work at http://www.paypal.me/mattneub. Or buy my books: the current (and final) editions are iOS 15 Programming Fundamentals with Swift and Programming iOS 14. Thank you!


Failures

In addition to cancellation and completion, there’s another way a publisher can stop publishing: it can fail. This means that the publisher has hit a snag from which it can’t recover. There is no point going on; the publisher is effectively dead in the water.

Failure Message

When a publisher fails, it needs a way to inform its downstream object of this fact. It does this by passing a failure message to the downstream object. This message is parallel to a completion message — in fact, it is a kind of completion message. That makes sense, because the failure means that the publisher is done, every bit as much as if it had completed in good order.

Recall that a completion message is actually an instance of the Subscribers.Completion enum, namely the .finished case. Well, a failure message is also an instance of the Subscribers.Completion enum, namely the .failure case. This case has an associated value which is an Error, thus giving the publisher a way to tell its downstream object what the snag is.

When a publisher fails, two things actually happen:

  • The publisher cancels itself. Thus, as I explained in the previous section, it stops publishing, and if this publisher is an operator so that its upstream object is another publisher, a cancel message is sent up the pipeline, unsubscribing all subscribers and cancelling all publishers.

  • The publisher emits a failure message. In general, a downstream operator, when it receives a failure message, will just pass that message further downstream. Therefore, by default, the failure message propagates all the way down the pipeline to the final subscriber.

Thus, by default, as soon as any publisher or operator anywhere along the chain produces an error, the entire pipeline is effectively dead. All publishers (and operators) upstream from the point of failure are cancelled, and the pipeline will not produce a value after that. Downstream, the failure message reaches the final subscriber, and that is typically the last signal it will receive.

NOTE: Actually, because of the asynchronous nature of publishers, it is possible that an already published value may arrive at the end of a pipeline some time after an error. But that’s just a detail. One way or another, a pipeline that has propagated an error all the way to the final subscriber is essentially finished.

To demonstrate, let’s go back to our example pipeline:

URLSession.shared.dataTaskPublisher(for: url)
    .compactMap { UIImage(data:$0.data) }
    .receive(on: DispatchQueue.main)
    .sink(receiveCompletion: {_ in}) {
        self.iv.image = $0
    }
    .store(in:&self.storage)

A data task publisher can fail with a URLError. (For example, the URL might be bad, or the network might be down.) Thus there are three possible scenarios for what this pipeline might do:

Fetching succeeds, conversion to UIImage succeeds
If the data task succeeds in fetching data, and if that data is in fact image data, then it is transformed into a UIImage which arrives at the sink subscriber.
Fetching succeeds, conversion to UIImage fails
If the data task succeeds in fetching data, but that data is not image data, then the signal stops at the compactMap operator and nothing arrives at the sink subscriber.
Fetching fails
If the data task fails with an error, it emits a failure message, a .failure completion wrapping the error, which is propagated right down the pipeline to the sink subscriber. Currently, our sink subscriber is ignoring completions, including failures; that is the significance of the empty receiveCompletion function! But if we wanted to, we could have the sink do something with this error.

Transmutation of Failures

Just as an operator is not obliged to republish downstream identically the same value it receives from its upstream publisher, so an operator is not obliged to pass a received failure message downstream unchanged. The failure message wraps some kind of Error; well, an operator can change this into a different type of Error, wrap that in a failure, and send it on downstream. An operator is itself a publisher, so it can generate an error of its own. It is also possible for an operator to effectively block errors from proceeding down the chain.

To illustrate, let’s say for purposes of demonstration that we don’t care about any errors at the subscriber end of the pipeline. If the data task fails, we don’t even want to hear about it. That way, the subscriber gets an image or nothing, no matter what; it will never receive a failure message. To block any failure message that comes down the pipeline, we’ll use the replaceError(with:) operator:

URLSession.shared.dataTaskPublisher(for: url)
    .map {$0.data} // *
    .replaceError(with: Data()) // *
    .compactMap { UIImage(data:$0) }
    .receive(on: DispatchQueue.main)
    .sink() { // *
        self.iv.image = $0
    }
    .store(in:&self.storage)

Let’s analyze that code. First, we use the map operator to eliminate the response part of the data task publisher’s value, so that all we have, if we have anything at all, is some Data. But we might not have anything at all; we might have a failure instead. So we now use the replaceError operator so that a failure, too, is transformed into some Data — some empty Data. Now no failure can proceed down the pipeline from this point! We are guaranteed that some Data object will emerge from the replaceError operator, either the data from the data task or an empty Data that we produced with the replaceError operator.

NOTE: The failure Error type from this point on downstream is said to be Never, meaning that the pipeline as a whole cannot fail.

Okay, so now the pipeline proceeds as before. We have reached the compactMap operator. Here, either we’ve got data from the data task or we’ve got data from the replaceError operator, but either way, we’ve got a Data object and we try to turn it into a UIImage. If the data from the data task is image data, we succeed, and the UIImage passes on down the pipeline. If the data from the data task is not image data, or if there was an error so that the data is empty, we are unable to make a UIImage from it and no value proceeds down the pipeline.

This means that, when we reach the sink, we are guaranteed that there is no failure coming our way; therefore we are permitted to eliminate the receiveCompletion parameter from the sink call. It is required if a failure is possible, but not if no failure is possible. And as you can see, I’ve done that in my code.

But now we can go even further. Because no failure is possible, we are allowed to replace the sink subscriber with a different subscriber — the assign subscriber. We can use this to assign the incoming image directly to the image view’s image property:

URLSession.shared.dataTaskPublisher(for: url)
    .map {$0.data}
    .replaceError(with: Data())
    .compactMap { UIImage(data:$0) }
    .receive(on: DispatchQueue.main)
    .assign(to: \.image, on: self.iv) // *
    .store(in:&self.storage)

There is something extraordinarily beautiful about what we’ve just done. We’ve created a pipeline all the way from the URLSession to our image view! If the URLSession’s data task succeeds in fetching an image from the network, that image just flows right into our image view, automatically. The operators are doing all the work; our pipeline functions as a direct connection from the network to the image property of our image view in the interface. In a sense, our interface is constructing itself by means of the pipeline. This illustrates the essence of the Combine framework.


Table of Contents