Sign in with GitHub to watch this episode for free.

Combine Essentials

When to Use Combine's TryMap Instead of Map

In an earlier installment of Combine Essentials, you learned about Combine's map operator, one of the most commonly used operators. The map operator has one important limitation. The closure you pass to the map operator can't be throwing. Don't worry, though. Combine's tryMap operator addresses this shortcoming.

Throwing Errors with Combine's TryMap Operator

Error handling isn't the most enjoyable aspect of software development, but it is necessary. This is especially true for networking. The response a server returns is dependent on a range of factors and your application needs to handle every response the server returns gracefully.

Let's say your application wants to fetch weather data from a remote API. We define a method that accepts the latitude and the longitude of the location for which your application wants to fetch weather data. We create a WeatherServiceRequest object and ask it for the URL of the request.

func fetchWeatherData(latitude: Double, longitude: Double) {
    // Create Weather Service Request
    let request = WeatherServiceRequest(latitude: latitude, longitude: longitude)
}

WeatherServiceRequest is nothing more than a lightweight struct that creates a URL from the coordinates we provide.

struct WeatherServiceRequest {
    
    // MARK: - Properties
    
    private let apiKey = "abcdef"
    private let baseUrl = URL(string: "https://awesomeweatherservice.com/")!
    private let fallbackBaseUrl = URL(string: "https://fantasticweatherservice.com/")!

    // MARK: -
    
    let latitude: Double
    let longitude: Double
    
    // MARK: - Public API

    var url: URL {
        url(for: baseUrl)
    }

    var fallbackUrl: URL {
        url(for: fallbackBaseUrl)
    }

    // MARK: - Helper Methods

    private func url(for baseUrl: URL) -> URL {
        // Create URL Components
        guard var components = URLComponents(url: baseUrl, resolvingAgainstBaseURL: false) else {
            fatalError("Unable to Create URL Components for Weather Service Request")
        }
        
        // Define Query Items
        components.queryItems = [
            URLQueryItem(name: "api_key", value: apiKey),
            URLQueryItem(name: "lat", value: "\(latitude)"),
            URLQueryItem(name: "long", value: "\(longitude)")
        ]
        
        guard let url = components.url else {
            fatalError("Unable to Create URL for Weather Service Request")
        }
        
        return url
    }

}

Using Combine, we send a request to the weather service. We create a data task publisher by invoking the dataTaskPublisher(for:) method on the shared URL session singleton, passing in the URL of the request.

func fetchWeatherData(latitude: Double, longitude: Double) {
    // Create Weather Service Request
    let request = WeatherServiceRequest(latitude: latitude, longitude: longitude)

    // Create Data Task Publisher
    URLSession.shared.dataTaskPublisher(for: request.url)
}

We use the map operator to map the response to a WeatherData object. We create and configure a JSONDecoder instance and decode the response. Notice that the Output type of the publisher the map operator returns is WeatherData?, an optional type. This isn't ideal. If decoding the response fails, we want to throw an error in true Swift fashion.

func fetchWeatherData(latitude: Double, longitude: Double) {
    // Create Weather Service Request
    let request = WeatherServiceRequest(latitude: latitude, longitude: longitude)

    // Create Data Task Publisher
    URLSession.shared.dataTaskPublisher(for: request.url)
        .map { data, response -> WeatherData? in
            // Create JSON Decoder
            let decoder = JSONDecoder()

            // Configure JSON Decoder
            decoder.dateDecodingStrategy = .secondsSince1970

            return try? decoder.decode(WeatherData.self, from: data)
        }
}

The map operator doesn't accept a throwing closure, though.

How to use Combine's tryMap operator

We can replace the map operator with the tryMap operator to work around this limitation. This means we can also change the Output type of the publisher the map operator returns to WeatherData, a non-optional type. That is a definite improvement in my book.

func fetchWeatherData(latitude: Double, longitude: Double) {
    // Create Weather Service Request
    let request = WeatherServiceRequest(latitude: latitude, longitude: longitude)

    // Create Data Task Publisher
    URLSession.shared.dataTaskPublisher(for: request.url)
        .tryMap { data, response -> WeatherData? in
            // Create JSON Decoder
            let decoder = JSONDecoder()

            // Configure JSON Decoder
            decoder.dateDecodingStrategy = .secondsSince1970

            return try decoder.decode(WeatherData.self, from: data)
        }
}

Defining a Custom Error

We can improve the implementation by defining a custom error that clearly communicates what the reason of the failure is. This is a pattern I use often because it simplifies downstream error handling. Let me show you what I have in mind. We define an enum, WeatherDataError, that conforms to Error. The enum defines two cases for now, failedRequest and invalidResponse.

enum WeatherDataError: Error {

    // MARK: - Cases
    
    case failedRequest
    case invalidResponse

}

Let's integrate WeatherDataError into the fetchWeatherData(latitude:longitude:) method. In the tryMap operator, we inspect the status code of the response. We consider the request a failure if the status code doesn't fall within the 200-299 range. In that scenario, we throw a WeatherDataError.failedRequest error.

func fetchWeatherData(latitude: Double, longitude: Double) {
    // Create Weather Service Request
    let request = WeatherServiceRequest(latitude: latitude, longitude: longitude)

    // Create Data Task Publisher
    URLSession.shared.dataTaskPublisher(for: request.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

            return try decoder.decode(WeatherData.self, from: data)
        }
}

There is one more improvement we can make. We decode the response in a do-catch statement to catch the error that might be thrown. In the catch clause, we log the error and throw a WeatherDataError.invalidResponse error.

func fetchWeatherData(latitude: Double, longitude: Double) {
    // Create Weather Service Request
    let request = WeatherServiceRequest(latitude: latitude, longitude: longitude)

    // Create Data Task Publisher
    URLSession.shared.dataTaskPublisher(for: request.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

            do {
                return try decoder.decode(WeatherData.self, from: data)
            } catch {
                print("Unable to Decode Response \(error)")
                throw WeatherDataError.failedRequest
            }
        }
}

Why is this useful? What is the value of catching an error and throwing another one? It ensures the errors the closure of the tryMap operator throws are of type WeatherDataError and that makes handling errors downstream a lot easier.

This technique works well in combination with the mapError operator. As the name suggests, the mapError operator maps errors from an upstream publisher to another error. In this example, we map the errors of the upstream publisher to WeatherDataError. This means that the resulting publisher has a Failure type of WeatherDataError instead of Error.

func fetchWeatherData(latitude: Double, longitude: Double) {
    // Create Weather Service Request
    let request = WeatherServiceRequest(latitude: latitude, longitude: longitude)

    // Create Data Task Publisher
    URLSession.shared.dataTaskPublisher(for: request.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

            do {
                return try decoder.decode(WeatherData.self, from: data)
            } catch {
                print("Unable to Decode Response \(error)")
                throw WeatherDataError.failedRequest
            }
        }
        .mapError { error -> WeatherDataError in
            error as? WeatherDataError ?? .failedRequest
        }
}

In the closure we pass to the mapError operator, we cast the error to WeatherDataError and fall back to WeatherDataError.failedRequest.

.mapError { error -> WeatherDataError in
    error as? WeatherDataError ?? .failedRequest
}

To wrap up the example, we subscribe to the publisher the mapError operator returns by invoking the sink(receiveCompletion:receiveValue:) method. Because the data task publisher emits the result of the request on a background thread, we apply the receive operator to ensure the result of the request is emitted on the main thread. This is important if the application uses the weather data to update its user interface.

func fetchWeatherData(latitude: Double, longitude: Double) {
    // Create Weather Service Request
    let request = WeatherServiceRequest(latitude: latitude, longitude: longitude)

    // Create Data Task Publisher
    URLSession.shared.dataTaskPublisher(for: request.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

            do {
                return try decoder.decode(WeatherData.self, from: data)
            } catch {
                print("Unable to Decode Response \(error)")
                throw WeatherDataError.failedRequest
            }
        }
        .mapError { error -> WeatherDataError in
            error as? WeatherDataError ?? .failedRequest
        }
        .receive(on: DispatchQueue.main)
        .sink(receiveCompletion: { completion in
            switch completion {
            case .finished:
                ()
            case .failure(let error):
                switch error {
                case .failedRequest:
                    ()
                case .invalidResponse:
                    ()
                }

                // Notify User
            }
        }, receiveValue: { weatherData in
            // Update User Interface
        }).store(in: &subscriptions)
}

What's Next?

Even though the map operator is indispensable, its sibling, tryMap, is equally useful if you need to handle errors. The tryMap operator works well in combination with the mapError operator as you learned in this episode.