Every Combine publisher defines two associated types, the Output
type defines the type of elements the publisher can emit and the Failure
type defines the type of errors the publisher can emit. The fact that a publisher is required to define an Output
type and a Failure
type is convenient, but it can sometimes be inconvenient. What do I mean by that? Take a look at the following example from Building a Modern Networking Layer in Swift.
import Combine
import Foundation
struct APIPreviewClient: APIService {
// MARK: - Methods
func episodes() -> AnyPublisher<[Episode], APIError> {
guard
let url = Bundle.main.url(forResource: "episodes", withExtension: "json"),
let data = try? Data(contentsOf: url),
let episodes = try? JSONDecoder().decode([Episode].self, from: data)
else {
fatalError("Unable to Load Episodes")
}
return Just(episodes)
.eraseToAnyPublisher()
}
}
The episodes()
method returns a publisher that has an Output
type of [Episode]
and a Failure
type of APIError
. In the body of the episodes()
method, the APIPreviewClient
struct loads data from a JSON file and creates a publisher using the Just
struct. A Just
instance is a publisher that emits a single element. We wrap the publisher in a type eraser by applying the eraseToAnyPublisher
operator. This seems fine, but the compiler throws an error.
Because a Just
instance emits a single element, it is guaranteed to not complete with an error. This means that its Failure
type is Never
. The problem is that the Failure
type doesn't match that of the publisher the episodes()
method returns, that is, APIError
.
Applying the MapError Operator
Let's take a look at a few options to resolve the issue. Can we use the mapError
operator to change the Failure
type of the publisher to APIError
? The compiler complains that Never
can't be cast to APIError
and that makes sense.
func episodes() -> AnyPublisher<[Episode], APIError> {
guard
let url = Bundle.main.url(forResource: "episodes", withExtension: "json"),
let data = try? Data(contentsOf: url),
let episodes = try? JSONDecoder().decode([Episode].self, from: data)
else {
fatalError("Unable to Load Episodes")
}
return Just(episodes)
.mapError { error in
error as APIError
}
.eraseToAnyPublisher()
}
Applying the SetFailureType Operator
Another option is applying the setFailureType
operator. As the name suggests, the setFailureType
operator replaces the Failure
type of the upstream publisher with a type you define. In this example, we use the setFailureType
operator to replace the Failure
type of the Just
instance, Never
, with APIError
.
func episodes() -> AnyPublisher<[Episode], APIError> {
guard
let url = Bundle.main.url(forResource: "episodes", withExtension: "json"),
let data = try? Data(contentsOf: url),
let episodes = try? JSONDecoder().decode([Episode].self, from: data)
else {
fatalError("Unable to Load Episodes")
}
return Just(episodes)
.setFailureType(to: APIError.self)
.eraseToAnyPublisher()
}
The only requirement is that the type you pass to the setFailureType(to:)
method conforms to the Error
protocol. The setFailureType
operator should only be applied to a publisher that cannot fail, that is, a publisher with a Failure
type of Never
. If the upstream publisher can fail, then you need to map the errors of the upstream publisher using the mapError
operator.
One of the key differences between the Combine framework and the RxSwift library is that a publisher (Combine) is required to explicitly define its Failure
type. The Failure
type of an observable (RxSwift) is always Error
. By explicitly defining the Failure
type of a publisher, error handling becomes easier and more transparent. The downside is that it can sometimes lead to issues you need to address as illustrated in the above example. That downside doesn't outweigh the benefits, though.
What's Next?
The Combine framework defines a number of operators to handle errors, such as tryMap
, mapError
, and tryCatch
. The setFailureType
operator is another operator that facilitates error handling.