Even though we successfully used the Combine framework to fetch data from the weather API, the implementation is incomplete. We ignored error handling up until now and it is time to take a closer look at errors and how to handle them.

Terminating a Subscription

Earlier in this series, you learned that a publisher can emit values and errors. Once a publisher emits an error, it is guaranteed to not emit any other events. The subscription is terminated the moment an error is emitted.

Errors are an integral aspect of the Combine framework and it is therefore important that you understand how to handle them. Even though the subscription is terminated the moment an error is emitted, it is possible to recover from an error.

I admit that error handling isn't the most enjoyable aspect of software development, but robust error handling is essential if your goal is building applications that are scalable and resilient.

Replacing Map with Try Map

In the previous episode, we naively assumed that the request for weather data always succeeds. Networks are unreliable and so are computers. There are plenty of reasons why a request for weather data can fail. The code we wrote in the previous episode needs to be more defensive. The map operator isn't a good fit. We replace it with the tryMap operator.

As the name suggests, the tryMap operator can be used to throw an error if things go wrong. Like the map operator, the closure we pass to the tryMap operator accepts a tuple as its only argument. The tuple consists of a Data object and a URLResponse object. We covered that in the previous episode. The closure we pass to the tryMap operator returns a WeatherData object.

// Create Data Task Publisher
URLSession.shared.dataTaskPublisher(for: url)
    .tryMap { data, response -> WeatherData in

    }
    .decode(type: WeatherData.self, decoder: decoder)
    .receive(on: DispatchQueue.main)
    .sink { completion in
        switch completion {
        case .finished:
            break
        case .failure(let error):
            print("Unable to Fetch Weather Data \(error)")
        }
    } receiveValue: { [weak self] weatherData in
        self?.weatherDataStateSubject.send(.data(weatherData))
    }
    .store(in: &subscriptions)

We first verify that the HTTP status code of the response falls within the success range. We use a guard statement to safely access the status code of the response. This means we need to cast the URLResponse object to an HTTPURLResponse object. Even though the cast should never fail, the view model throws an error if it does.

// Create Data Task Publisher
URLSession.shared.dataTaskPublisher(for: url)
    .tryMap { data, response -> WeatherData in
        guard let response = response as? HTTPURLResponse else {
            throw WeatherDataError.failedRequest
        }
    }
    .decode(type: WeatherData.self, decoder: decoder)
    .receive(on: DispatchQueue.main)
    .sink { completion in
        switch completion {
        case .finished:
            break
        case .failure(let error):
            print("Unable to Fetch Weather Data \(error)")
        }
    } receiveValue: { [weak self] weatherData in
        self?.weatherDataStateSubject.send(.data(weatherData))
    }
    .store(in: &subscriptions)

We use another guard statement to verify that that status code of the response falls within the success range. We define a half open range and pass the status code of the response to the contains(_:) method to inspect the outcome of the request for weather data. The tryMap operator throws an error if the request was unsuccessful.

// Create Data Task Publisher
URLSession.shared.dataTaskPublisher(for: url)
    .tryMap { data, response -> WeatherData in
        guard let response = response as? HTTPURLResponse else {
            throw WeatherDataError.failedRequest
        }

        guard (200..<300).contains(response.statusCode) else {
            throw WeatherDataError.failedRequest
        }
    }
    .decode(type: WeatherData.self, decoder: decoder)
    .receive(on: DispatchQueue.main)
    .sink { completion in
        switch completion {
        case .finished:
            break
        case .failure(let error):
            print("Unable to Fetch Weather Data \(error)")
        }
    } receiveValue: { [weak self] weatherData in
        self?.weatherDataStateSubject.send(.data(weatherData))
    }
    .store(in: &subscriptions)

Now that we know the request was successful, we can use the data of the response to create a WeatherData object. We remove the decode operator and move the decoding of the data to the tryMap operator. We use a guard statement and the try? keyword to safely decode the data of the response. The decode(_:from:) method accepts the type of the decoded data as its first argument and a Data object as its second argument. The tryMap operator returns the WeatherData object if decoding the data is successful. It throws an error if decoding the data is unsuccessful.

// Create Data Task Publisher
URLSession.shared.dataTaskPublisher(for: url)
    .tryMap { data, response -> WeatherData in
        guard let response = response as? HTTPURLResponse else {
            throw WeatherDataError.failedRequest
        }

        guard (200..<300).contains(response.statusCode) else {
            throw WeatherDataError.failedRequest
        }

        guard let weatherData = try? decoder.decode(WeatherData.self, from: data) else {
            throw WeatherDataError.invalidResponse
        }

        return weatherData
    }
    .receive(on: DispatchQueue.main)
    .sink { completion in
        switch completion {
        case .finished:
            break
        case .failure(let error):
            print("Unable to Fetch Weather Data \(error)")
        }
    } receiveValue: { [weak self] weatherData in
        self?.weatherDataStateSubject.send(.data(weatherData))
    }
    .store(in: &subscriptions)

We can improve the implementation by merging the first and the second guard statement, and by moving the initialization and configuration of the JSONDecoder instance to the tryMap operator.

// Create Data Task Publisher
URLSession.shared.dataTaskPublisher(for: url)
    .tryMap { data, response -> WeatherData in
        guard
            let response = response as? HTTPURLResponse,
            (200..<300).contains(response.statusCode)
        else {
            throw WeatherDataError.failedRequest
        }

        // Create JSON Decoder
        let decoder = JSONDecoder()

        // Configure JSON Decoder
        decoder.dateDecodingStrategy = .secondsSince1970

        guard let weatherData = try? decoder.decode(WeatherData.self, from: data) else {
            throw WeatherDataError.invalidResponse
        }

        return weatherData
    }
    .receive(on: DispatchQueue.main)
    .sink { completion in
        switch completion {
        case .finished:
            break
        case .failure(let error):
            print("Unable to Fetch Weather Data \(error)")
        }
    } receiveValue: { [weak self] weatherData in
        self?.weatherDataStateSubject.send(.data(weatherData))
    }
    .store(in: &subscriptions)

Mapping Errors

Even though the tryMap operator throws errors of type WeatherDataError, it is important to understand that the tryMap operator returns a publisher with Failure type URLError, the Failure type of the data task publisher. It would be more convenient if the Failure type were WeatherDataError because the view model knows how to work with WeatherDataError. The mapError operator can be used to replace the Failure type of an upstream publisher. The upstream publisher in this example is the publisher returned by the tryMap operator. Let me show you how that works.

We apply the mapError operator to the publisher the tryMap operator returns. The closure we pass to the mapError operator accepts a single argument, the upstream failure. In this example, the upstream failure is of type URLError. The return value of the closure is WeatherDataError. That is the Failure type of the downstream publisher, the publisher the mapError operator returns. We use the upstream failure that is passed to the closure to create a WeatherDataError. The implementation is simple. The mapError operator casts the error to WeatherDataError. If that cast fails, it returns WeatherDataError.failedRequest.

// Create Data Task Publisher
URLSession.shared.dataTaskPublisher(for: url)
    .tryMap { data, response -> WeatherData in
        guard
            let response = response as? HTTPURLResponse,
            (200..<300).contains(response.statusCode)
        else {
            throw WeatherDataError.failedRequest
        }

        // Create JSON Decoder
        let decoder = JSONDecoder()

        // Configure JSON Decoder
        decoder.dateDecodingStrategy = .secondsSince1970

        guard let weatherData = try? decoder.decode(WeatherData.self, from: data) else {
            throw WeatherDataError.invalidResponse
        }

        return weatherData
    }
    .mapError{ error -> WeatherDataError in
        error as? WeatherDataError ?? .failedRequest
    }
    .receive(on: DispatchQueue.main)
    .sink { completion in
        switch completion {
        case .finished:
            break
        case .failure(let error):
            print("Unable to Fetch Weather Data \(error)")
        }
    } receiveValue: { [weak self] weatherData in
        self?.weatherDataStateSubject.send(.data(weatherData))
    }
    .store(in: &subscriptions)

Because the Failure type of the resulting publisher is WeatherDataError, we can handle the error in the completion closure of the sink(receiveCompletion:receiveValue:) method. We no longer print the error to the console in the failure case of the switch statement. Instead, we create a WeatherDataState object and pass it to the send(_:) method of weatherDataStateSubject.

// Create Data Task Publisher
URLSession.shared.dataTaskPublisher(for: url)
    .tryMap { data, response -> WeatherData in
        guard
            let response = response as? HTTPURLResponse,
            (200..<300).contains(response.statusCode)
        else {
            throw WeatherDataError.failedRequest
        }

        // Create JSON Decoder
        let decoder = JSONDecoder()

        // Configure JSON Decoder
        decoder.dateDecodingStrategy = .secondsSince1970

        guard let weatherData = try? decoder.decode(WeatherData.self, from: data) else {
            throw WeatherDataError.invalidResponse
        }

        return weatherData
    }
    .mapError{ error -> WeatherDataError in
        error as? WeatherDataError ?? .failedRequest
    }
    .receive(on: DispatchQueue.main)
    .sink { [weak self] completion in
        switch completion {
        case .finished:
            break
        case .failure(let error):
            self?.weatherDataStateSubject.send(.error(error))
        }
    } receiveValue: { [weak self] weatherData in
        self?.weatherDataStateSubject.send(.data(weatherData))
    }
    .store(in: &subscriptions)

Failure Type

I very much like how Apple implemented error handling in Combine. A publisher is required to define its Output type and its Failure type. In other words, a publisher promises to only emit values of type Output and only throw errors of type Failure. This makes error handling easier and more transparent. For example, we know that a data task publisher only throws an error of type URLError.

In RxSwift, an observable explicitly defines its Output type. That is a requirement. RxSwift doesn't offer the option to explicitly define the Failure type of an observable. The Failure type of an observable is always Error.

Because a publisher is required to define its Failure type, error handling becomes more transparent and predictable using Combine.

What's Next?

The solution we implemented in this episode isn't the only solution. Combine defines a range of operators to handle errors. In the next episode, we explore a few other options and you learn how easy it is to retry a failed request.