/ COMBINE, SWIFT

Error Handling in Swift Combine Framework

Errors are inevitable part of our programming process, and the Swift Combine Framework is not an exception. While we cannot completely eliminate errors, we should focus our efforts on different ways of recovering from them. In present article let’s study Combine’s way of handling errors.

Basic understanding of Combine is recommended for this article. I suggest reading the bird-eye overview of the Combine framework before you proceed.

Error Handling Strategies

Error handling is the process of responding to and recovering from error conditions in your app [1].

Once an error occurs, the two things we can do about it is to terminate or recover. Let’s see how we can achieve this with the Swift Combine Framework.

Terminating the App

The most straightforward solution is not to handle errors at all. Although I recommend against shipping such code to real users, this strategy may be useful for development. While debugging, we often want to crash as early and as loudly as possible. This makes it easy to track down and fix errors in our Swift code. Here are two more scenarios when want not to worry about error handling:

  • When writing experimental code.
  • When teaching or studying.

Combine provides the assertNoFailure(_:file:line:) method for this purpose. It terminates an app in case previous publisher sends an error. It will also print debugging information to help you track down the issue.

💡 If you are sure that an error can never occur in your stream, set publisher’s Failure type to Never. It disables errors propagation on syntax level. It is much safer than assertNoFailure(_:file:line:), that can potentially crash your app.

Fallback Strategies

It’s often too extreme to finish Combine stream with a failure, after which no more events can be processed. In some cases errors are expected to happen. It can be that you are searching for a non-existent term, having an unstable network connection or mistyping a phone number in a text field. As a rule of thumb, when an error is expected and not fatal to the stream, we want to recover from it. The strategies for doing this are: catching, replacing, retrying and skipping. Let’s discuss each one in detail.

Catching and Replacing Errors

The catch(_:) method provides a way to replace failed publisher with another publisher. Typically, you’ll use catching to replace an error with some kind of placeholder data, such as:

  1. Data loading error with an empty data set.
  2. Image that failed to load with a placeholder image.
  3. Username that failed to load with “unknown” placeholder.

replaceError(with:) acts similarly to the catch(_:) method, except it replaces an error with an element, instead of creating new publisher. The difference is best demonstrated with a code snippet:

struct DummyError: Error {}

// 1
Just(1)
   .tryMap { _ in throw DummyError() }
   .catch { _ in Just(2) }
   .sink { print($0) }

// 2
Just(1)
   .tryMap { _ -> Int in throw DummyError() }
   .replaceError(with: 2)
   .sink { print($0) }

Both streams print 2, but achieve this differently:

  1. catch replace current publisher with entirely new publisher with a value 2.
  2. replaceError substitutes an error with a single value 2.

Retrying Errors

Retry gives your subscription a second chance by re-creating it for a given number of attempts. After all attempts are exhausted, retry(_:) propagates an error downstream.

💡 catch attempts to fix an error downstream, while retry does this upstream.

It’s common to retry publishers that we hope will complete without an error. Some examples are: network requests, user input fields that have some sort of validation.

let url = URL(string: "https://www.vadimbulavin.com")
   
URLSession.shared.dataTaskPublisher(for: url!)
   .retry(3) // Retry network request up to 3 times
   .receive(on: DispatchQueue.main)
   .sink(receiveCompletion: { print($0) },
         receiveValue: { _ in })

Mapping Errors

Infrastructure layer is the most common source of errors (database, network etc.). However, these errors are too low-level and need to be translated into the higher-level language your app speaks.

In Layered Architecture we discuss why it’s important to separate your Swift app into layers, explain layers design and communication between them.

To achieve this we need a way to transform low-level errors, such as the HTTP 404 Not Found Error, into the ones that make sense for the app in its current state. Swift Combine Framework provides the mapError(_:) operator for this purpose.

// 1
enum APIError: Error {
   case userIsOffline
   case somethingWentWrong
}

// 2
let errorPublisher = Result<Int, Error>.Publisher(URLError(.notConnectedToInternet))

// 3       
errorPublisher
   .mapError { error -> APIError in
       switch error {
       case URLError.notConnectedToInternet:
           return .userIsOffline
       default:
           return .somethingWentWrong
       }
   }
   .sink(receiveCompletion: { print($0) }, receiveValue: { print($0) })
  1. Declare custom error type.
  2. Create Result publisher that fails with URLError.
  3. Subscribe to that publisher, transforming generic Error into APIError.

Similarly to Just, the Result publisher emits single element and then completes. The difference is that Result may complete with either success or failure, but Just always succeeds.

It will print:

failure(... APIError.userIsOffline)

Raising Errors

Combine has a family of try-prefixed operators, which accept an error-throwing closure and return a new publisher. The publisher terminates with a failure in case an error is thrown inside a closure. Here are some of such methods: tryMap(_:), tryFilter(_:), tryScan(_:_:), you see the pattern here. There are 12 try-operators as of Xcode 11.1.

Summary

Let’s recap what we’ve just discussed about error handling with Combine. Here is what we can do in case of error:

  • Terminate an app with assertNoFailure(_:file:line:). This may be useful for development and debugging.
  • Replace failed publisher with a value by means of replaceError(with:).
  • Replace failed publisher with a completely new publisher using catch(_:).
  • Give your subscription a second chance with retry(_:).

You can transform an error into another error with mapError(_:).

Further reading

If you want to learn more about the Swift Combine Framework, I have some articles for you:


Thanks for reading!

If you enjoyed this post, be sure to follow me on Twitter to keep up with the new content. There I write daily on iOS development, programming, and Swift.

Vadim Bulavin

Creator of Yet Another Swift Blog. Coding for fun since 2008, for food since 2012.

Follow