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.
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.