Error handling: with both propagation and specific handling

My use case: I am performing 3 or more operations in a row. Each operation can throw an error and I want to properly propagate these errors up the chain.

enum OperationError: Error {
    case anything
}
enum LargeOperationError: Error { 
    case parseFailureField(field: String, message: String)
}
func performLargeOperation() -> Result<T, LargeOperationError> {
    let result1 = operation1()
    // if result1 is .failure, return a new .failure(.parseFailureField("op 1", result1's error description))
    // else, continue in the same scope with the .success variable
    
    let result2 = operation2()
    // if result2 is .failure, return a new .failure(.parseFailureField("op 2", result2's error description))
    // else, continue in the same scope with the .success variable
    
    let result3 = operation3()
    // if result3 is .failure, return a new .failure(.parseFailureField("op 3", result1's error description))
    // else, continue in the same scope with the .success variable
    
    // do something with the success results of the above three operations
}

My real-world use case for this is deserializing fixed width integers off of a network byte stream and constructing a packet object with the information.

Approach 1: Do/get/catch
Here, we can use .get() throws of the Result<Success, Failure> and catch everything and throw a new error from there.

do {
    let value1 = try operation1().get()
    let value2 = try operation2().get()
    let value3 = try operation3().get()
    return .success(T(value1, value2, value3))
} catch is OperationError {
    return .failure(.parseFailureField("???", error.description))
}

The problem here is that we don't know which operation of the three failed. The failure we can return can propagate the information of the originally returned failure (error.description), but the calling code would not know which part failed in here.

Approach 2: guard
Here, we can use guard statements to capture the information of which operation failed.

let result1 = operation1()
guard case let .success(value1) = result1 else {
    return .failure(.parseFailureField("operation 1", "???"))
}
let result2 = operation2()
guard case let .success(value2) = result2 else {
    return .failure(.parseFailureField("operation 2", "???"))
}
let result3 = operation3()
guard case let .success(value3) = result3 else {
    return .failure(.parseFailureField("operation 3", "???"))
}
return return .success(T(value1, value2, value3))

We've sacrificed our error propagation for our ability to provide detailed, specific error reporting. We can report which of the parts within our larger operation has failed which aids in debugging, but we don't have information about what caused the error anymore.

Approach 3: nested switch
This is the safest way to properly handle everything as needed.

let result1 = operation1()
switch result1 {
case let .failure(err):
    return .failure(.parseFailureField("operation 1", err.description))
case let .success(value1):

    let result2 = operation2()
    switch result2 {
    case let .failure(err):
        return .failure(.parseFailureField("operation 2", err.description))
    case let .success(value2):

        let result3 = operation3()
        switch result3 {
        case let .failure(err):
            return .failure(.parseFailureField("operation 3", err.description))
        case let .success(value3):
            return return .success(T(value1, value2, value3))
        }
    }
}

This follows the "return early" pattern [0], but it's nested and isn't linear. We can improve this somewhat like so.

let result1 = operation1()
let value1: T
switch result1 {
case let .failure(err):
    return .failure(.parseFailureField("operation 1", err.description))
case let .success(_value1):
    value1 = _value1
}

let result2 = operation2()
let value2: T
switch result2 {
case let .failure(err):
    return .failure(.parseFailureField("operation 2", err.description))
case let .success(_value2):
    value2 = _value2
}
let result3 = operation3()
let value3: T
switch result3 {
case let .failure(err):
    return .failure(.parseFailureField("operation 3", err.description))
case let .success(_value3):
    value3 = _value3
}
return return .success(T(value1, value2, value3))

We now have both propagation of the original error, as well as more specific error handling. But wow, look at all of the code we need to add for something that should be a lot simpler. This is unfortunately the best solution I've found so far.

Approach 4: the forbidden ! operator
We can vastly simplify the code, at the expense of safety.

let result1 = operation1()
guard case let .success(value1) = result1 else {
    return .failure(.parseFailureField("operation 1", result1.error!.description))
}
let result2 = operation2()
guard case let .success(value2) = result2 else {
    return .failure(.parseFailureField("operation 2", result2.error!.description))
}
let result3 = operation3()
guard case let .success(value3) = result3 else {
    return .failure(.parseFailureField("operation 3", result3.error!.description))
}
return return .success(T(value1, value2, value3))

extension Result {
    var error: Failure? {
        switch self {
        case let .failure(err):
            return err
        case .success(_):
            return nil
        }
    }
}

But if someone messes up the code somehow, we suddenly get unexpected force unwraps of the nil .error property in the extension, which is something to avoid.

Discussion
It's at this point that I'm not sure how best to move forward. My goals would be to minimize the amount of boilerplate code, to handle both propagation and field-specific handling as outlined above, and to avoid unsafe practices such as using the force unwrap operator.

Is this where using Result's .map, .flatMap, .mapError, and/or .flatMapError functions would come into play, possibly?

Some guidance on how to handle errors with these constraints would be appreciated!

Possible proposal
Pondering the above, I had the idea of a change to the guard statement to make this all easier. Essentially, while the guard case let binds a variable to the scope outside of the block, if it's binding to a Result<Success, Failure> (or possibly any enum with two cases), there could be a new bound variable on the inside of the guard statement of the other case. This would essentially be syntactical sugar to Approach 3. If this post does not yield sufficient results, I may make a separate post outlint this in more detail under the Evolution category of the forum.

[0]: https://www.itamarweiss.com/personal/2018/02/28/return-early-pattern.html

One thing I can think of, is to have a temporary variable for the current query:

do {
  var current = "op 1"
  _ = operator1()
} catch {
  //use Current here
}

It may also be worthwhile to have operator1 itself throws LargeOperationError. IMO, performLargeOperation knows too much about how errors should be handle. Rather, if you can’t make do with OperationError, it probably contains too little information to be of use.

Personally, I don’t think wrapping errors is much more beneficial in Swift error system anyway.

It’s not really a problem if you’re using stdlib Result. The values are local, and so guarantees success to those unwraps anyway. This is a very safe usage of force unwrap.

You can also do something like this:

// Note: 'operationKeyMap' is a tuple array instead of a dictionary to ensure order.
let operationKeyMap: [(String, () -> Result<T, OperationError>)] = [("op1", operation1), ("op2", operation2), ("op3", operation3)]
let finalResult: Result<[T], LargeOperationError> = Result {
    try operationKeyMap.map {
        switch $0.1() {
            case .success(let value): return value
            // This will cause an early termination of the map loop.
            case .failure(let error): throw LargeOperationError.parseFailureField($0.0, error.description)
        }
    }
// This is needed because the throws returns an 'Error'
}.flatMapError { .failure($0 as! LargeOperationError) }

switch finalResult {
  case .success(let values): // Do something with [T]
  case .failure(error): // Do something with LargeOperationError.parseFailureField
}

Given that you just want to capture the “operation number”, it might be worth encoding it into the OperationError enum instead. If an operation function (like operation1) fails, it can just return .failure(OperationError.whatever(step: 0)) and you can extract it from the error (perhaps via a convenience stepValue property on the enum).

How about this?


struct FailedResult: Error {
    var mesage: String
    var cause: Error

    init(_ message: String, _ cause: Error) {
        self.mesage = message
        self.cause = cause
    }
}

do {
    let a = try operation1().mapError { FailedResult("op1", $0) }.get()
    let b = try operation2().mapError { FailedResult("op2", $0) }.get()
    let c = try operation3().mapError { FailedResult("op3", $0) }.get()
    print(a, b, c)
} catch let error as FailedResult {
    print(error.mesage, error.cause)
} catch {
    fatalError("unexpect type: \(error)")
}
Terms of Service

Privacy Policy

Cookie Policy