Error handling in Combine Swift
In this post we read about error handling process and tools in Combine framework. This article is one of article series on Combine Framework.
Table of contents
- Happy and Sad paths
- Never fails
- setFailureType operator
- assign operator and error handling
- assertNoFailure operator
- try operators
- Mapping error
Happy and Sad paths
Generally when we implement the logic of our application we plan everything based on the happy path and with having the idea that everything will goes according to the plan, but in reality it’s not the case.
Publishers in Combine are generics of type <Output, Failure>. In this article we pay attention to the sad path or Failure type and error handling.
Never Fails
Publishers which their Failure type is of Never, as the name implies can never fail.
let subscriptions = Set<Cancellable>()
Just("This publisher never fails")
.sink(receiveValue: { print($0) })
.store(in: &subscriptions)
This can be useful in certain scenarios where we want to guarantee that a certain sequence of operations will not fail. For when updating the UI, we often want to ensure that the updates don’t fail. Using a publisher with a Failure
type of Never
can be useful.
sink(receiveValue:)
is a safe overload to be used in case of subscribing to Publishers who never fails.
setFailureType operator
The setFailureType
operator helps us to set a failure type for our infallible Never failure types publishers.
enum NetworkError: Error {
case notRechable
}
let subscriptions = Set<AnyCancellable>()
Just("This publisher never fails")
.setFailureType(to: NetworkError.self)
.sink(
receiveCompletion: { completion in
switch completion {
// 2
case .failure(.notRechable):
print("The end point was not accessible finished with failure")
case .finished:
print("Finished successfully!")
}
},
receiveValue: { value in
print("The value is: \(value)")
}
)
.store(in: &subscriptions)
It is also useful for converting errors to something compatible with our pipeline.
Let’s consider a scenario where we have two publishers: one that emits a string and completes without failure, and another that emits an integer and can fail with an URLError
.
let stringPublisher = Just("Hello, Combine!")
let url = URL(string: "https://www.example.com")!
let urlPublisher = URLSession.shared.dataTaskPublisher(for: url)
In this case, stringPublisher
has a Failure
type of Never
, while urlPublisher
has a Failure
type of URLError
. If we want to combine these two publishers using the zip
operator, we’ll run into a problem because zip
requires both publishers to have the same Failure
type.
This is where setFailureType
comes in handy. We can use it to change the Failure
type of stringPublisher
to URLError
, making it compatible with urlPublisher
:
let stringPublisher = Just("Hello, Combine!")
.setFailureType(to: URLError.self)
let url = URL(string: "https://www.example.com")!
let urlPublisher = URLSession.shared.dataTaskPublisher(for: url)
let zippedPublisher = stringPublisher.zip(urlPublisher)
Now, zippedPublisher
is a valid publisher that emits a tuple containing a string and the result of the URL data task, and can fail with a URLError
. This is an example of how setFailureType
can be used to ensure pipeline compatibility in Combine.
Note that setFailureType
only works on publishers that cannot fail.
assign operator and error handling
There are two overload of assign operator, first lets talk about assign(to:,on:)
.
assign(to:,on:)
operator works only for publishers that cannot fail. It’s because sending error to a key path provided can result either in unhandled error or undefined behaviour.
let subscriptions = Set<AnyCancellable>()
class Student {
let studentID = UUID()
var name = ""
var familyName = ""
var age = 0
}
let student = Student()
Just("Ario")
.assign(to: \.name, on: student)
.store(in: &subscriptions)
print(studen.name)
//Consoel output
//Ario
assign(to:,on:)
strongly captures the provided objects, and it can cause to retain cycle.
class Test: ObservableObject {
@Published var counter = 1
init() {
Timer.publish(every: 1, on: .main, in: .common)
.autoconnect()
.prefix(3)
.assign(to: \.counter, on: self)
.store(in: &subscriptions)
}
}
let vm = Test()
vm.$currentDate
.sink(receiveValue: { print($0) })
.store(in: &subscriptions)
To prevent this retain cycle we use assign(to:)
. The operator specifically deals with reassigning published values to a @Published
property by providing an inout reference to its projected publisher.
assertNoFailure operator
The assertNoFailure
operator is useful when we want to make sure that our publisher cannot fails and finishes with failure event. It doesn’t prevent a failure from being emitted by the upstream, but rather it will crash the app with fatalError.
Just("Ariobarxan")
.setFailureType(to: CustomError.self) //CustomError is a self implemented error type.
.assertNoFailure()
.tryMap { _ in throw CustomError.error }
.sink(receiveValue: { print("value received: \($0) ")})
.store(in: &subscriptions)
try operators
try*
operators are a way to produce failure if there is one. All these operators have same mechanism to deal with error propagation.
import Combine
let numbers = [1, 2, 3, 4, 5].publisher
let subscription = numbers
.tryMap { value -> Int in
if value == 3 {
throw CustomError.someError
}
return value * 2
}
.sink(receiveCompletion: { completion in
switch completion {
case .failure(let error):
print("Error: \(error)")
case .finished:
print("Finished")
}
}, receiveValue: { value in
print("Received value: \(value)")
})
enum CustomError: Error {
case someError
}
The important note about try
operators is that they erase the error type to plain SwiftError type.
Mapping Error
After using tryMap which results in erasing the error type, we can use mapError
operator to reset the error to what we want. The solve to this problem is to call mapError
operator immediately after the try*
operator.
import Combine
enum CustomError: Error {
case someError
case unknown
}
let numbers = [1, 2, 3, 4, 5].publisher
let subscription = numbers
.tryMap { value -> Int in
if value == 3 {
throw CustomError.someError
}
return value * 2
}
.mapError { error -> CustomError in
if let customError = error as? CustomError {
return customError
} else {
return .unknown
}
}
.sink(receiveCompletion: { completion in
switch completion {
case .failure(let error):
print("Error: \(error)")
case .finished:
print("Finished")
}
}, receiveValue: { value in
print("Received value: \(value)")
})