Handling errors is one of the less enjoyable aspects of software development, but it is an important one. You don't want to show the user a cryptic error message when something goes wrong, or worse, no error message. There is no clear-cut recipe you can follow. Every project is different. The good news is that error handling is built into Swift and the Combine framework. Let me show you how we can improve the code we wrote in the previous episode.

Retrying a Failed Request

We already handled one important error, a failed request. Remember from the previous episode that a request is retried once if it fails. In that scenario, we don't handle the error explicitly. We simply give the request another try. If the request failed due to a transient network or backend error, the second attempt has a reasonable chance of succeeding. What is nice about this approach is that the user is unaware of the second attempt. The fewer times we need to interrupt the user with bad news the better.

Defining a Custom Error

Later in this series, we create an API client that is responsible for communicating with the mock API. The view model communicates with the mock API through the API client. It is the task of the API client to handle errors and forward errors to the view model, but only those errors that the view model understands. What do I mean by that?

A common mistake developers make is propagating errors downstream. This means that errors aren't handled but forwarded instead. The problem with this approach is that the downstream object receiving the error doesn't have the context it needs to appropriately handle the error.

That is something we can avoid by defining a custom error type. Create a group with name Networking and add a Swift file with name APIError.swift. We define an enum, APIError, that conforms to the Error protocol. There are plenty of things that can go wrong when sending a request to a remote API. Take a look at the URLError enum if you are curious. The view model is only interested in a small subset. We define a failedRequest case and an invalidResponse case. I usually also define an unknown case.

import Foundation

enum APIError: Error {

    // MARK: - Cases

    case unknown
    case failedRequest
    case invalidResponse

}

The unknown case may seem like one of those easy ways out when you don't know what to do, but that isn't entirely accurate. In most projects I work on, errors are sent to a remote server. This is useful for monitoring the stability of the application. If you notice that the application is logging unknown errors, you need to investigate why that is. It is acceptable that errors occur, but unknown errors are a red flag. An unknown error indicates that an unexpected code path was triggered. In other words, you need to find out what went wrong and possibly extend the error type with a new case to handle the unknown errors. In short, it is expected for things to go wrong, but you need to know exactly what can go wrong. There should be no surprises.

Transforming Errors

With APIError in place, we can refactor the implementation of the fetchEpisodes() method in EpisodesViewModel. We start by removing the map and decode operators. We apply the tryMap operator instead. As the name suggests, the tryMap operator is a variant of the map operator. The difference is that the function you pass to the tryMap operator can be throwing. Like the map operator, the function that we pass to the tryMap operator accepts a Data object and a URLResponse object and it returns an array of Episode objects.

URLSession.shared.dataTaskPublisher(for: request)
    .tryMap { data, response -> [Episode] in

    }
    .receive(on: DispatchQueue.main)
    .sink(receiveCompletion: { [weak self] completion in
        self?.isFetching = false

        switch completion {
        case .finished:
            print("Successfully Fetched Episodes")
        case .failure(let error):
            print("Unable to Fetch Episodes \(error)")
        }
    }, receiveValue: { [weak self] episodes in
        self?.episodes = episodes
    }).store(in: &subscriptions)

In the closure of the tryMap operator, we inspect the status code of the response and throw a failedRequest error if the status code doesn't fall within the success range, that is, between 200 and 299.

URLSession.shared.dataTaskPublisher(for: request)
    .tryMap { data, response -> [Episode] in
        guard
            let response = response as? HTTPURLResponse,
            (200..<300).contains(response.statusCode)
        else {
            throw APIError.failedRequest
        }
    }
    .receive(on: DispatchQueue.main)
    .sink(receiveCompletion: { [weak self] completion in
        self?.isFetching = false

        switch completion {
        case .finished:
            print("Successfully Fetched Episodes")
        case .failure(let error):
            print("Unable to Fetch Episodes \(error)")
        }
    }, receiveValue: { [weak self] episodes in
        self?.episodes = episodes
    }).store(in: &subscriptions)

We use a do-catch statement to decode the response. This is what the decode operator does for you. The difference is that we catch the error the decoder throws and throw an APIError instead.

URLSession.shared.dataTaskPublisher(for: request)
    .tryMap { data, response -> [Episode] in
        guard
            let response = response as? HTTPURLResponse,
            (200..<300).contains(response.statusCode)
        else {
            throw APIError.failedRequest
        }

        do {
            return try JSONDecoder().decode([Episode].self, from: data)
        } catch {
            print("Unable to Decode Response \(error)")
            throw APIError.invalidResponse
        }
    }
    .receive(on: DispatchQueue.main)
    .sink(receiveCompletion: { [weak self] completion in
        self?.isFetching = false

        switch completion {
        case .finished:
            print("Successfully Fetched Episodes")
        case .failure(let error):
            print("Unable to Fetch Episodes \(error)")
        }
    }, receiveValue: { [weak self] episodes in
        self?.episodes = episodes
    }).store(in: &subscriptions)

Notice that we print the error the decoder throws to the console. This is important because the information the error holds is essential to debug the problem. In production, you would send the error to a remote server, not the console.

Even though we throw a custom error in the tryMap operator, the Failure type of the publisher the tryMap operator returns is URLError, not APIError. This is a problem because it defeats the purpose of throwing a custom error. We can change the Failure type of the publisher by applying the mapError operator to the publisher the tryMap operator returns. The closure we pass to the mapError(_:) method accepts the upstream error as an argument. In the closure we cast the error to APIError. If that cast fails, we return a failedRequest error.

URLSession.shared.dataTaskPublisher(for: request)
    .tryMap { data, response -> [Episode] in
        guard
            let response = response as? HTTPURLResponse,
            (200..<300).contains(response.statusCode)
        else {
            throw APIError.failedRequest
        }

        do {
            return try JSONDecoder().decode([Episode].self, from: data)
        } catch {
            print("Unable to Decode Response \(error)")
            throw APIError.invalidResponse
        }
    }
    .mapError { error -> APIError in
        error as? APIError ?? .failedRequest
    }
    .receive(on: DispatchQueue.main)
    .sink(receiveCompletion: { [weak self] completion in
        self?.isFetching = false

        switch completion {
        case .finished:
            print("Successfully Fetched Episodes")
        case .failure(let error):
            print("Unable to Fetch Episodes \(error)")
        }
    }, receiveValue: { [weak self] episodes in
        self?.episodes = episodes
    }).store(in: &subscriptions)

Let's go through a few examples of how this works. Let's say the mock API returns a server error, a 500 response. The tryMap operator transforms that response into a failedRequest error and the mapError operator simply propagates the error since it receives an APIError object. If the mock API returns a response the JSON decoder isn't able to decode, the tryMap operator transforms the error the JSON decoder throws into an invalidResponse error. As before, the mapError operator propagates the error since it receives an APIError object. Things are different if the data task publisher terminates with an error. The Failure type of a data task publisher is URLError. The mapError operator catches the error and transforms it to an APIError object, a failedRequest error to be precise.

We can always improve the implementation of the mapError operator over time. Let me give you an example of what I mean. Revisit APIError.swift and add a case, unreachable, for the scenario in which the device doesn't have a network connection.

enum APIError: Error {

    // MARK: - Cases

    case unknown
    case unreachable
    case failedRequest
    case invalidResponse

}

Head back to EpisodesViewModel.swift. In the closure we pass to the mapError operator, we use a switch statement to differentiate between the possible errors the upstream publisher can emit. In the first case, we cast the upstream error to an APIError object and return the error without transforming it. In the second case, we check if the error is equal to URLError.notConnectedToInternet. In that scenario, we transform the URLError to an APIError. In the default clause, we fall back to failedRequest.

.mapError { error -> APIError in
    switch error {
    case let apiError as APIError:
        return apiError
    case URLError.notConnectedToInternet:
        return APIError.unreachable
    default:
        return APIError.failedRequest
    }
}

Before we end this episode, we need to notify the user if something goes wrong. We define a property, errorMessage, of type String? and prefix it with the Published property wrapper. We declare the setter privately.

@Published private(set) var errorMessage: String?

In the completion handler of the sink(receiveCompletion:receiveValue:) method, we update the errorMessage property by asking the error for the value of its message property.

.sink(receiveCompletion: { [weak self] completion in
    self?.isFetching = false

    switch completion {
    case .finished:
        print("Successfully Fetched Episodes")
    case .failure(let error):
        print("Unable to Fetch Episodes \(error)")
        self?.errorMessage = error.message
    }
}, receiveValue: { [weak self] episodes in
    self?.episodes = episodes
}).store(in: &subscriptions)

That message property doesn't exist yet. Open APIError.swift and define a computed message property of type String. We return a message for each of the cases of the APIError enum.

enum APIError: Error {

    // MARK: - Cases

    case unknown
    case unreachable
    case failedRequest
    case invalidResponse

    // MARK: - Properties

    var message: String {
        switch self {
        case .unreachable:
            return "You need to have a network connection."
        case .unknown,
             .failedRequest,
             .invalidResponse:
            return "The list of episodes could not be fetched."
        }
    }

}

Before we test the implementation, we need to update EpisodesView. In the else clause of the ZStack we add an if-else statement. We use the if statement to safely unwrap the value of the view model's errorMessage property. If errorMessage has a value, we display it to the user using a Text object. If errorMessage doesn't have a value, we display the list of episodes.

var body: some View {
    NavigationView {
        ZStack {
            if viewModel.isFetching {
                ProgressView()
                    .progressViewStyle(.circular)
            } else {
                if let errorMessage = viewModel.errorMessage {
                    Text(errorMessage)
                } else {
                    List(viewModel.episodeRowViewModels) { viewModel in
                        ...
                    }
                    .listStyle(.plain)
                }
            }
        }
        .navigationTitle("What's New")
    }
}

To test the implementation, we add a typo to the URL of the endpoint to fetch the list of episodes and run the application in a simulator. You should see an error message being displayed as a result.

Displaying an Error Message

What's Next?

With error handling in place, it is time to move the networking logic to a dedicated type, an API client. The view model uses the API client to communicate with the mock API.