Changing a publisher’s Failure type in Combine

Published on: April 15, 2020

One of Combine's somewhat painful to work with features is its error mechanism. In Combine, publishers have an Output type and a Failure type. The Output represents the values that a publisher can emit, the Failure represents the errors that a publisher can emit. This is really convenient because you know exactly what to expect from a publisher you subscribe to. But what happens when you have a slightly more complicated setup? What happens if you want to transform a publisher's output into a new publisher but the errors of the old and new publishers don't line up?

The other day I was asked a question about this. The person in question wanted to know how they could write an extension on Publisher that would transform URLRequest values into URLSession.DataTaskPublisher values so each emitted URLRequest would automatically become a network request. Here's what my initial experiment looked like (or rather, the code I would have liked to write):

extension Publisher where Output == URLRequest {
  func performRequest() -> AnyPublisher<(data: Data, response: URLResponse), Error> {
    return self
      .flatMap({ request in
        return URLSession.shared.dataTaskPublisher(for: request)
      })
      .eraseToAnyPublisher()
  }
}

Not bad, right? But this doesn't compile. The following error appears in the console:

instance method 'flatMap(maxPublishers::)' requires the types 'Self.Failure' and 'URLSession.DataTaskPublisher.Failure' (aka 'URLError') be equivalent_

In short, flatMap requires that the errors of the source publisher, and the one I'm creating are the same. That's a bit of a problem because I don't know exactly what the source publisher's error is. I also don't know how an if I can map it to URLError, or if I can map URLError to Self.Failure.

Luckily, we know that Publisher.Failure must conform to the Error protocol. This means that we can erase the error type completely, and transform it into a generic Error instead with Combine's mapError(_:) operator:

extension Publisher where Output == URLRequest {
  func performRequest() -> AnyPublisher<(data: Data, response: URLResponse), Error> {
    return self
      .mapError({ (error: Self.Failure) -> Error in
        return error
      })
      .flatMap({ request in
        return URLSession.shared.dataTaskPublisher(for: request)
          .mapError({ (error: URLError) -> Error in
            return error
          })
      })
      .eraseToAnyPublisher()
  }
}

Note that I apply mapError(_:) to self which is the source publisher and to the URLSession.DataTaskPublisher that's created in the flatMap. This way, both publishers emit a generic Error rather than their specialized error. The upside is that this code compiled. The downside is that when we subscribe to the publisher created in performRequest we'll need to figure out which error may have occurred. An alternative to erasing the error completely could be to map any errors emitted by the source publisher to a failing URLRequest:

extension Publisher where Output == URLRequest {
  func performRequest() -> AnyPublisher<(data: Data, response: URLResponse), URLError> {
    return self
      .mapError({ (error: Self.Failure) -> URLError in
        return URLError(.badURL)
      })
      .flatMap({ request in
        return URLSession.shared.dataTaskPublisher(for: request)
      })
      .eraseToAnyPublisher()
  }
}

I like this solution a little bit better because we don't lose all error information. The downside here is that we don't know which error may have occurred upstream. Neither solution is ideal but the point here is not for me to tell you which of these solutions is best for your app. The point is that you can see how to transform a publisher's value using mapError(_:) to make it fit your needs.

Before I wrap this Quick Tip, I want to show you an extension that you can use to transform the output of any publisher into a generic Error:

extension Publisher {
  func genericError() -> AnyPublisher<Self.Output, Error> {
    return self
      .mapError({ (error: Self.Failure) -> Error in
        return error
      }).eraseToAnyPublisher()
  }
}

You could use this extension as follows:

extension Publisher where Output == URLRequest {
  func performRequest() -> AnyPublisher<(data: Data, response: URLResponse), Error> {
    return self
      .genericError()
      .flatMap({ request in
        return URLSession.shared.dataTaskPublisher(for: request)
          .genericError()
      })
      .eraseToAnyPublisher()
  }
}

It's not much but it saves a couple of lines of code. Be careful when using this operator though. You lose all error details from upstream publishers when in favor of slightly better composability. Personally, I think your code will be more robust when you transform errors to the error that's needed downstream like I did in the second example. It makes sure that you explicitly handle any errors rather than ignoring them.

If you have any questions or feedback about this Quick Tip make sure to reach out on Twitter

Categories

Combine Quick Tip

Subscribe to my newsletter