Error handling in Combine

Michał Cichecki
Aug 1 · 8 min read

When writing an iOS app you are probably dealing with a lot of asynchronous code whether it is a function which takes the argument as a completion handler, notification or KVO. You might be also familiar with libraries like RxSwift or PromiseKit.

A unified, declarative API for processing values over time

Now there is a native solution — Combine. It is a brand new reactive framework from Apple which was introduced at the beginning of June during WWDC19. Combine stands on top of many asynchronous interfaces that can be found in your codebase and provides unified API for processing values over time.


Publishers, Subscribers and more

There are three key concepts: Publishers, Subscribers and Operators. Both Publisher and Subscriber protocol have two associated types. The first one is Input/Output which defines a kind of values published/received by Publisher/Subscriber. Publisher’s receive(subscriber:) method requires Subscriber’s Input to match one’s Output type.

Second associated type is Failure which must conform to Error protocol. It describes type of the error that can be published/received by Publisher/Subscriber or in other words — how they can fail.

Publisher is a protocol that defines how values and errors are produced over time.

There are four kinds of messages:

  • subscription — connection of a subscriber to a publisher
  • value — an element in the sequence, the publisher can produce zero or more values
  • error — first terminal message. Indicates that sequence ended with error
  • complete — second terminal message. Indicates that publisher finished successfully

On the receiving end, there is Subscriber. They describe three events that can occur in one’s lifetime and are related to the four messages described above.

  • receive(subscription: Subscription) — successfully subscribed to the publisher. Called exactly once
  • receive(_ value: Input) -> Subscribers.Demand — Publisher can provide zero or more values to the Subscriber
  • receive(completion: Subscribers.Completion<Failure>) — Publisher can send exactly one completion. It can indicate successful completion or error

As you can see errors are important when handling streams of values. Dealing with them might be significant to make sure to display a correct error message or handle failed API requests.


Types of Errors

Since Publisher and Subscriber protocols add type constraints to the associated types you can use any custom type conforming to Error protocol.

As an example let’s use CurrentValueSubject which conforms to the Subject protocol which lets you publish elements with send(_:) function. Subject protocol extends the Publisher protocol so initializing it requires providing types for both Output and Failure.

CurrentValueSubject stores all values it received and must be constructed with the initial value (as opposed to PassthroughSubject).

While working with Combine you are going to see that a lot of Publishers will describe their failure type as Never. This indicates that they can never fail or that failure has been handled earlier.

Never is very useful for representing impossible code. Most people are familiar with it as the return type of functions like fatalError, but Never is also useful when working with generic classes.

It might be common to define Result type with Never as its Failure type to represent that something can never produce an error.

An example of this behavior in Combine can be NotifcationCenter and its new Publisher struct implementing Publisher protocol with Failure type of Never.

Error Handling in Practice

To see how different error handling operators behave in practice I’m going to use Swift Playground.

This code should give us good insight at what exactly happens when values, errors, and completions are produced.

When you run the above code you will see three emitted values and one failure. Finish won’t be called because failure is a terminal message and indicates that the publisher won’t produce any additional elements.

 value: initial value value: 1st value value: 2nd value❗️ failure: defaultSubjectError

assertNoFailure

The first error handling operator raises a fatalError when the Publisher emits an error.

func assertNoFailure(
_ prefix: String = "",
file: StaticString = #file,
line: UInt = #line
) -> Publishers.AssertNoFailure<Self>

To match Never type let’s add new onNeverCompletion handler and assertNoFailure() before attaching to sink.

Instead of error in the console, our program execution will be stopped with an error message similar to one caused by fatalError() method.

 value: initial value value: 1st value value: 2nd value// runtime error instead of failure

That’s a situation when other error handling operators might come in handy because it’s rarely desirable to cause the runtime exception and application to stop.

catch

One of the most useful error-handling operators is catch which lets you replace the current publisher which caused an error with another publisher. This method defines a closure which takes error as an argument and returns Publisher.

func `catch`<P>(
_ handler: @escaping (Self.Failure) -> P
) -> Publishers.Catch<Self, P>
where
P : Publisher,
Self.Output == P.Output

In the closure, you can intercept an error and return new publisher accordingly. In this example, I’m just returning a new Just publisher. It emits a value just once and then finishes. It also does not produce any errors.

A Just publisher is also useful when replacing a value with catch.

Now instead of error we can successfully receive a value and finish the subscription. One thing to keep in mind is that in this situation our subscription is terminated.

 value: initial value value: 1st value value: 2nd value value: recovering from the error: defaultSubjectError🏁 finished

mapError

Another handy operator is mapError which converts errors into any new error. It might be useful when fetching and converting data to give theuser the most meaningful information about what went wrong.

func mapError<E>(
_ transform: @escaping (Self.Failure) -> E
) -> Publishers.MapError<Self, E>
where
E : Error

This code example results in a different type of error than was originally published. In most situations, you are going to use switch case statement to match and return an appropriate error.

 value: initial value value: 1st value value: 2nd value❗️ failure: unknown

setFailureType

Another operator that might be helpful in some situations is setFailureType . By looking at its declaration in the documentation one thing you are going to notice is that it is declared as Publisher’s extension with a where clause indicating that it can only be used when Publisher never fails(Self.Failure == Never).

func setFailureType<E>(
to failureType: E.Type
) -> Publishers.SetFailureType<Self, E>
where
E : Error

When you try calling it on publisher with Error not being Never you will see this compiler error message:

❗️ Referencing instance method 'setFailureType(to:)' on 'Publisher' requires the types 'Error' and 'Never' be equivalent

The easiest way to simulate this behavior is to instantiate subject with Never or use one we’ve got already declared and call assertNoFailure . Now it is possible to set failure type to match the completion.

 value: initial value value: 1st value value: 2nd value// runtime error instead of failure

This code will result in runtime exception because that’s a default way of handling errors when they are declared as Never . Additionally this method might be also used on Justpublisher to match completion handler which doesn’t expect Never .

retry

Last but not least there is retry. It takes one argument which specifies the number of retries that going to happen in cases of error. After number of retries is exceeded the publisher will pass the error to the subscriber.

func retry(
_ retries: Int
) -> Publishers.Retry<Self>

retry is very useful when working with URLSession . Instead of resuming data tasks recursively when the server returned an error it is much easier to just call retry and restart the chain of Combine operators.

replaceError

One of the most basic errors handling operators is replaceError which simply replaces an error with provided value and finishes normally.

func replaceError(
with output: Self.Output
) -> Publishers.ReplaceError<Self>

In this example error will be replaced with provided the String.

 value: initial value value: 1st value value: 2nd value value: replaced error🏁 finished

Practical example with URLSession

One of the most common examples of dealing with errors is making URL requests where error can arise due to the internet connection, server problems or decoding. URLSession now comes with functions that return URLSession.DataTaskPublisher . It’s a new struct that conforms to Publisher protocol. Its Output is a tuple containing Data and URLResponse while Failure is URLError .

func dataTaskPublisher(
for url: URL
) -> URLSession.DataTaskPublisher
func dataTaskPublisher(
for request: URLRequest
) -> URLSession.DataTaskPublisher

Decoding data to user-defined type can be achieved with a new decode function which takes expected type and decoder as arguments.

func decode<Item, Coder>(
type: Item.Type,
decoder: Coder
) -> Publishers.Decode<Self, Item, Coder>
where
Item : Decodable,
Coder : TopLevelDecoder,
Self.Output == Coder.Input

Combining these operators with previously described ones (map, mapError etc.) lets you chain them all together which results in more linear and shorter code.

The above snippet presents an example of dealing with data decoding and error matching to give a user more meaningful message about what went wrong, whether it’s a problem with the internet connection or an internal problem with data decoding.

eraseToAnyPublisher at the end of the chain of operators is used to hide details of the operators that were used. If it was not used here, return type of this expression would look like this:

Publishers.Retry<Publishers.MapError<Publishers.Decode<Publishers.Map<URLSession.DataTaskPublisher, JSONDecoder.Input>, Item, JSONDecoder>, ServiceError>>

Errors in Combine are passed downstream and when the first error occurs all following operators (except ones dealing with errors) will be skipped. In our example once there is a connection problem no other operator than mapError and retry is called.

In the picture below you can see all the operations with their corresponding Output and Failure types. Green color marks operations which primarily deal with dealing with input/output and red color applies to operators which deal with errors (mapping, retrying failed subscription).

Data mapping and error handling

Where to go from here…

Combine framework will become more common among iOS developers when SwiftUI becomes a standard (official release in September 2019).

Even if you don’t start using SwiftUI in your apps, Combine has a lot to offer. The only requirement is that your app’s target is set to at least iOS 13.0. There are a lot of new features that make common tasks like handling user’s input or making URL request easier with Publishers. Also, there are a lot of different ways of dealing with error and understanding them. I hope you will find this article helpful and use Combine’s error handling features in your projects.

All of the code snippets used in this article can be found in the following repo:

Sources

  1. Apple Developer Documentation: Combine Framework
  2. WWDC19 Session 721: Combine in Practice
  3. WWDC19 Session 712: Advances in Networking, Part 1
  4. NSHipster: Never
  5. Swift Evolution: Never Proposal

codequest

We turn ideas into awesome software

Thanks to Piotr Żużel

Michał Cichecki

Written by

iOS Dev

codequest

codequest

We turn ideas into awesome software

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade