Handle common error and delegate other errors

My architecture defines a UseCase in that way:

public protocol UseCase<Parameters, UseCaseResult> {
    associatedtype Parameters: Sendable
    associatedtype UseCaseResult: Sendable
        
    func execute(_ parameters: Parameters) async throws -> UseCaseResult
}

Use cases are injected in ViewModels as any UseCase<X,Y>. These ViewModels implement a ViewModel protocol, where I want to define a method to execute a UseCase and catch a NetworkError.noConnection, to handle it in a common way. Other errors should be managed by concrete ViewModels, so I want something like that:

extension ViewModel { 
func executeUseCase<P, R>(_ useCase: sending some UseCase<P, R>,
                              parameters: P) async throws -> R? {
        do {
            return try await useCase.execute(parameters)
        } catch let networkError as NetworkError {
            if networkError == .connectionError {
                handleNoConnectionError()
                return nil
            }
            throw networkError
        }
    }
}

The problem here is that a UseCase can return void, so developers could forget to check if the return is nil in that case (when network error is handled) and continue with execution (not desired).
I know I can use closures to achieve that, but I don't want to loose what structured concurrency and throws offer to me.
Throwing a custom error when a network error is handled would cause an empty catch in all my ViewModels.

How can achieve that in an idiomatic and well structured way?

EDIT: I am totally open to approaching the solution in a totally different way, this is just a proposal for which I have not found a solution that satisfies me...

So in other words: Even if a given UseCase's UseCaseResult is Void, the executeUseCase's return value should be checked, as it can return nil (so the actual return value is a Void?)?

I think in this case simply allowing the UseCaseResult itself as the optional return value is not sufficient and you would need a dedicated "pseudo-optional", i.e. an enum that better communicates what happened:

enum UseCaseOutcome<O: Sendable> {
    case handledNetworkError
    case handledWithResult(O)
}

func executeUseCase<P, R>(_ useCase: sending some UseCase<P, R>,
                          parameters: P) async throws -> UseCaseOutcome<R> {
    do {
        let retVal = try await useCase.execute(parameters)
        return .handledWithResult(retVal)
    } catch let networkError as NetworkError {
        if networkError == .connectionError {
            handleNoConnectionError()
            return handledWithNetworkError
        }
        throw networkError
    }
}

I admit that's still kind of cumbersome as in many cases, but I don't see another way to enforce a specific check.

However, why do you not want to throw an error even if the network error was handled anyway? If I understand this correctly, you still basically require developers to do something specific (i.e. not just treat the use case as delivering the expected result), so I would think handling this with a specific (custom) error in a catch block fits in line with how other, non-handled errors are treated, no? So:

func executeUseCase<P, R>(_ useCase: sending some UseCase<P, R>,
                          parameters: P) async throws -> R {
    do {
        return try await useCase.execute(parameters)
    } catch let networkError as NetworkError {
        if networkError == .connectionError {
            handleNoConnectionError()
            throw NetworkError.handledNoConnectionError
        }
        throw networkError
    }
}
1 Like

These two statements are at odds with each other:

Move the code from handling the optional, to handling another error case.

I.e. instead of this:

func whatever<P, R>(
  _ useCase: sending some UseCase<P, R>,
  parameters: P
) async {
  do {
    guard let result = try await executeUseCase(useCase, parameters: parameters) else {
      // further NetworkError.connectionError handling
      return
    }
    // handle result
  } catch {
    // handle other errors
  }
}

Do this. If you don't want to throw a custom error, don't bother. Just throw nil.

func executeUseCase<P, R>(
  _ useCase: sending some UseCase<P, R>,
  parameters: P
) async throws -> R {
  do {
    return try await useCase.execute(parameters)
  } catch NetworkError.connectionError {
    handleNoConnectionError()
    throw nil as R?.Nil
  }
}

func whatever<P, R>(
  _ useCase: sending some UseCase<P, R>,
  parameters: P
) async {
  do {
    let result = try await executeUseCase(useCase, parameters: parameters)
    // handle result
  } catch is R?.Nil {
    // further NetworkError.connectionError handling
  } catch {
    // handle other errors
  }
}
public extension Optional {
  struct Nil: Swift.Error & Equatable {
    @available(*, unavailable) private init() { }
  }
}

extension Optional.Nil: ExpressibleByNilLiteral {
  @inlinable public init(nilLiteral: Void) { }
}

I agree with you, maybe I would need this "pseudo-optional", but I also think that's cumbersome...
9 of 10 cases I won't want to handle connection error in a specific way, so I would get many empty catch blocks. My point is that ViewModels should catch each UseCase to show a custom error, forgetting about NetworkError.connectionError and only managing this one when neeeded (1/10 cases). My typical VM would need something like:

class MyViewModel: ViewModel {
  private let loginUseCase: any UseCase<LoginUseCase.Params, Void>
  ...
  func someButtonTapped(username: String, password: String) {
        Task {
            do {
                try await executeUseCase(loginUseCase,
                                         parameters: LoginUseCase.Parameters(username: username,
                                                                             password: password))
                wireframe.trigger(.setTabs)
            } catch LoginError.invalidCredentials {
                showError(alert: .init(title: "Invalid credentials"))
            } catch {
                // NetworkError.noConnectionError should not reach here
                showError(alert: .init(title: "Unknown login error"))
            }
        }
    }
}

If I need to handle network connection in a different way, I could override handleNetworkError() in my VM, but I think that's a corner case.

Good approach, but the reason I don't want to throw a custom error is to avoid to have many empty catchs, so this solution doesn't fit to me.

Without you showing us more of this, I don't think there's any evidence that it's not isomorphic to however you'd handle nil.

You shouldn't be throwing NetworkError out of executeUseCase if you don't plan to handle every case. Throw a subset.

I agree with @Danny here, without more context on the usage side it's hard to recommend a solution, but I cannot imagine a solution that works without some sort of additional branch, be that a weird check for whether a Void result is nil or something in a catch block.
An empty catch block is basically the same as a block around that nil-check that does nothing.

However, I believe if you want to have NetworkError.noConnectionError to be catchable, but not be able to arrive on the general catch block, it needs to be grouped into one of the specific catch blocks (in your example into the one that deals with LoginError. I assume you would want to keep distinct LoginError and NetworkError types, so putting them into a kind of umbrella protocol can work, I think:

protocol UmbrellaError: Error { } // not to be associated with the Umbrella Corporation...

enum LoginError: UmbrellaError { // I assume it originally directly adopted Error?
    case invalidCredentials //, ...
}
enum NetworkError: UmbrellaError { // same
    case noConnectionError //, ...
}

// ...
    func someButtonTapped(username: String, password: String) {
        Task {
            do {
                try await executeUseCase(loginUseCase,
                                         parameters: LoginUseCase.Parameters(username: username,
                                                                             password: password))
                wireframe.trigger(.setTabs)
            } catch let umbrellaError as UmbrellaError {
                switch umbrellaError {
                case LoginError.invalidCredentials:
                    showError(alert: .init(title: "Invalid credentials"))
                // ... other cases for LoginError
                case is NetworkError:
                    // maybe do something general?
                default:
                    // ...
            } catch {
                // NetworkError.noConnectionError should not reach here
                showError(alert: .init(title: "Unknown login error"))
            }
        }
    }

Other "nesting" between your errors is also possible depending on how you define protocols and what types the errors are (if you're using classes you could inherit, but I'd advise against that).
The point is that you have one catch for all your special & important errors (maybe all public ones your framework exposes) and one for "the rest" (unknown ones, less important ones, etc.).
Users can then decide whether they check for explicit ones or not themselves. You do not force anybody to have an "empty" catch block if your error types properly cover the bases.
Above example would, for example work with my second suggestion: Use a special case for "handled connection error" and let users decide whether they check for it or not. As long as it is wrapped together with others in the first catch, you're good.

That's my point, I don't like to return nil... In my example I only throw networkError from my executeUseCase() when NetworkError is not .connectionError. I'm looking for a proper way to handle common errors and delegate in callers handling the subset of errors that are not common.

Let's forget my executeUseCase method approach because I believe I wont find a good solution. The question is: how would you model your errors to satisfy these conditions:

  • UseCases can throw NetworkError(connectionError, serverError, invalidResponse...) or custom business errors (i.e. LoginError.invalidCredentials (actually a map for a 401 networkError))
  • ViewModels execute use cases and define a catch for bussiness error and for other errors. Example:
private func login(username: String, password: String) {
        Task {
            do {
                try await loginUseCase.execute(LoginUseCase.Parameters(username: username, 
                                                                       password: password))
                wireframe.trigger(.setTabs)
            } catch LoginError.invalidCredentials {
                showError(alert: .init(title: "Invalid credentials"))
            } catch NetworkError.noConnection {
                showError(alert: .init(title: "No connection"))
            } catch {
                showError(alert: .init(title: "Error login"))
            }
        }
}

My point is that 9/10 ViewModels will have:

catch NetworkError.noConnection {
  showError(alert: .init(title: "No connection"))
}

in each UseCase execution. So I would like to silent that catch for them. Any idea how to achieve that in a proper way?

It kind of seems like you'd wish for the ability to infer what errors a caller explicitly provides catch blocks for in the called function at this point. This is not going to be possible per se.

I.e. if you want to spare the 9/10 calls the repetitive catch NetworkError.noConnection block, why not just give the function a simple boolean parameter to instruct it to handle that case for you?
If somebody then sets that to "handle it for me" and provides a catch block: Not really harmful, just dead code.
If they set it the other way around and do not provide a catch block: Dang, they swallowed that error into the general catch, but is that really that terrible?

If it is possible to throw that error (i.e. you have to expose it for that 1/10), users will have to deal with it, that's just fundamental to errors. Whether they swallow it in a general catch block or omit to check for a nil result, there's nothing you can do about that. I'd say that's not even your job... :smiley:
Personally I'd go for that "do it for me" boolean and not care if somebody then writes an unreachable catch block...
You might even default that bool to true (i.e. "handle network Errors for me") if 9/10 times that's the better option.

1 Like

Good point @Gero , thanks for your answer. I agree with you, maybe I was looking for a more "typed" solution, but you've convinced me. I'll go for the boolean.

1 Like

Process the error and throw if you can't.

private func login(username: String, password: String) {
  Task {
    do {
      try await loginUseCase.execute(
        LoginUseCase.Parameters(username: username, password: password)
      )
      wireframe.trigger(.setTabs)
    } catch {
      do {
        try defaultStuff(error)
      } catch LoginError.invalidCredentials {
        showError(alert: .init(title: "Invalid credentials"))
      } catch {
        showError(alert: .init(title: "Error login"))
      }
    }
  }
}
func defaultStuff(_ error: any Error) throws {
  guard case NetworkError.noConnection = error else { throw error }
  showError(alert: .init(title: "No connection"))
}

If the doom pyramid above bugs you, you can map the error instead.

Task {
  do {
    try await loginUseCase.execute(
      LoginUseCase.Parameters(username: username, password: password)
    )
    wireframe.trigger(.setTabs)
  } catch {
    try defaultStuff(error)
  }
}.mapError { error in
  switch error {
  case LoginError.invalidCredentials:
    showError(alert: .init(title: "Invalid credentials"))
  default:
    showError(alert: .init(title: "Error login"))
  }
}
extension Task {
  @discardableResult func mapError(
    _ transform: sending @escaping @isolated(any) (Failure) async -> Success
  ) -> Task<Success, Never> {
    .init {
      do throws(Failure) { return try await result.get() }
      catch { return await transform(error) }
    }
  }
}
1 Like

Very nice solution! I haven't realized that I could call an error handling method instead of an execute one. Love your approach!

1 Like