Error handling in Combine Swift

Ario Liyan
Dev Genius
Published in
5 min readApr 19, 2024

--

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)")
})

--

--

As an iOS developer with a passion for programming concepts. I love sharing my latest discoveries with others and sparking conversations about technology.