Earlier in this series, we declared the computed message property in the APIError enum. While that seemed like a good idea at that time, the previous episode showed that we need a solution that is more flexible. The APIError enum doesn't have the context it needs to define a human-readable message for each of its cases.
Separation of Concerns
Before we can make changes, we need to decide which object is responsible for mapping an API error to a human-readable message. The object should have the context it needs to map the API error to a message the user understands.
The API client doesn't have the context it needs to map the API error. Even if it would have the required context, the API client shouldn't be coupled to the user interface. Mapping an API error to a message the user understands isn't a responsibility of the API client.
The view has the required context to map the API error to a human-readable message. This is a convenient option because the view can map and display the API error to the user. This option may seem appealing, but it goes against a core concept of the Model-View-ViewModel pattern. A view should be dumb. It should display what it is given and it shouldn't be concerned with mapping an API error to a message the user understands.
The object that should be responsible for mapping the API error is the view model. A view model mediates between the user interface layer and the business layer of the application. It is perfectly fine for a view model to map an API error to a message the user understands.
Mapping an API Error to a Message
We need to make a few changes. Open EpisodesViewModel.swift and open APIError.swift in the Assistant Editor on the right. Remove the computed message property of the APIError enum. We move the switch statement of the computed message property to the failure case of the fetchEpisodes() method of EpisodesViewModel.
enum APIError: Error {
// MARK: - Cases
case unknown
case unreachable
case failedRequest
case invalidResponse
}
Notice that we wrap the switch statement in a self-executing closure that returns a string. The return value of the self-executing closure is assigned to the view model's errorMessage property. The switch statement switches on the associated value of the failure case, an APIError object.
private func fetchEpisodes() {
isFetching = true
apiService.episodes()
.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 = {
switch error {
case .unreachable:
return "You need to have a network connection."
case .unknown,
.failedRequest,
.invalidResponse:
return "The list of episodes could not be fetched."
}
}()
}
}, receiveValue: { [weak self] episodes in
self?.episodes = episodes
}).store(in: &subscriptions)
}
You may be wondering why we use a self-executing closure. We could assign the string returned by each case to the view model's errorMessage property. The subtle benefit of this approach is that a single value is assigned to the view model's errorMessage property. This means that the compiler throws an error if one of the cases doesn't return a string. If we don't use a self-executing closure and we forget to assign a value to the errorMessage property in one of the cases, the compiler won't warn us. It's a subtle benefit that I quite like.
Delegating the Mapping of an API Error
The changes we made so far have improved the implementation. First, the APIError enum is no longer responsible for mapping its cases to human-readable messages. Second, the episodes view model has the context it needs to map an API error to a message. There is still room for improvement, though.
The implementation of the fetchEpisodes() method can get lengthy as we add more cases to the APIError enum and we would need to duplicate that code if the list of episodes is fetched elsewhere in the project. We can improve the implementation by delegating the mapping of an API error to a dedicated type. That type encapsulates the mapping of API errors. Let me show you how this works.
Create a group Helpers. Add a Swift file with name APIErrorMapper.swift to the Helpers group. Define a struct, APIErrorMapper. The struct defines two properties, error of type APIError and context of type Context.
import Foundation
struct APIErrorMapper {
// MARK: - Properties
let error: APIError
let context: Context
}
Don't worry about the Context type for now. The APIErrorMapper struct defines a computed message property of type String. The computed message property returns a human-readable message. We implement this computed property in a moment.
import Foundation
struct APIErrorMapper {
// MARK: - Properties
let error: APIError
let context: Context
// MARK: - Public API
var message: String {
}
}
The idea is simple. An APIErrorMapper object is initialized with an error and a context. The mapper uses the Context object to map the error to a message. This brings us to the Context type. Context is a nested enum of APIErrorMapper. As the name suggests, it defines the context in which the error is thrown. We can keep it simple for now and define a single case, episodes, for fetching the list of episodes.
import Foundation
struct APIErrorMapper {
// MARK: - Types
enum Context {
case episodes
}
// MARK: - Properties
let error: APIError
let context: Context
// MARK: - Public API
var message: String {
}
}
Open EpisodesViewModel.swift in the Assistant Editor on the right and move the switch statement that switches on the APIError object from EpisodesViewModel.swift to the computed message property of APIErrorMapper.
import Foundation
struct APIErrorMapper {
// MARK: - Types
enum Context {
case episodes
}
// MARK: - Properties
let error: APIError
let context: Context
// MARK: - Public API
var message: String {
switch error {
case .unreachable:
return "You need to have a network connection."
case .unknown,
.failedRequest,
.invalidResponse:
return "The list of episodes could not be fetched."
}
}
}
Notice that we don't use the Context object in the computed message property. This isn't necessary since the Context enum defines a single case. This changes later in this episode.
The change we need to make to the fetchEpisodes() method is simple. We create an instance of the APIErrorMapper struct, passing in the APIError object and episodes as the context. We assign the value of the computed message property of the APIErrorMapper object to the view model's errorMessage property.
private func fetchEpisodes() {
isFetching = true
apiService.episodes()
.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 = APIErrorMapper(
error: error,
context: .episodes
).message
}
}, receiveValue: { [weak self] episodes in
self?.episodes = episodes
}).store(in: &subscriptions)
}
We no longer need to worry about code duplication, the fetchEpisodes() method is short and readable, and the APIErrorMapper struct is very easy to unit test.
Updating the Sign In View Model
Let's do the same for the sign in view. Open APIErrorMapper.swift and add a case to the Context enum with name signIn.
import Foundation
struct APIErrorMapper {
// MARK: - Types
enum Context {
case signIn
case episodes
}
// MARK: - Properties
let error: APIError
let context: Context
// MARK: - Public API
var message: String {
switch error {
case .unreachable:
return "You need to have a network connection."
case .unknown,
.failedRequest,
.invalidResponse:
return "The list of episodes could not be fetched."
}
}
}
We need to update the computed message property for the signIn case. We add a switch statement to the unknown, failedRequest, and invalidResponse case and switch on the value of the context property, returning a different message for the signIn case.
import Foundation
struct APIErrorMapper {
// MARK: - Types
enum Context {
case signIn
case episodes
}
// MARK: - Properties
let error: APIError
let context: Context
// MARK: - Public API
var message: String {
switch error {
case .unreachable:
return "You need to have a network connection."
case .unknown,
.failedRequest,
.invalidResponse:
switch context {
case .signIn:
return "The email/password combination is invalid."
case .episodes:
return "The list of episodes could not be fetched."
}
}
}
}
Open SignInViewModel.swift. In the failure case of the switch statement in the completion handler, we create an APIErrorMapper object, passing in the error and the signIn context. We assign the value of the computed message property of the APIErrorMapper object to the view model's errorMessage property.
func signIn() {
guard canSignIn else {
return
}
isSigningIn = true
errorMessage = nil
apiService.signIn(email: email, password: password)
.sink(receiveCompletion: { [weak self] completion in
self?.password = ""
self?.isSigningIn = false
switch completion {
case .finished:
()
case .failure(let error):
self?.errorMessage = APIErrorMapper(
error: error,
context: .signIn
).message
}
}, receiveValue: { [weak self] response in
self?.keychainService.setAccessToken(response.accessToken)
self?.keychainService.setRefreshToken(response.refreshToken)
}).store(in: &subscriptions)
}
No One-Size-Fits-All
It is important to emphasize that you learned about one way to handle errors. The solution we implemented in this episode fits the current needs of the project. But there are plenty of other viable solutions. Which solution best fits your project depends on the requirements of your project. It is therefore important to tailor your solution to the needs of your project.
If your application makes one or two network requests, then creating an object that maps errors to human-readable messages isn't necessary. A simple switch statement is sufficient in that scenario. If your project makes dozens of network requests, then error handling may require a more scalable solution that offers more flexibility.
What's Next?
The more time you invest in error handling, the more likely it is your users enjoy using your application even if things go wrong. It also makes debugging much easier. It pays to spend some time crafting a solution that is flexible, extensible, and transparent.