Retargeting throws to Results

I'm curious what others think about being able to "retarget" throws. Here's the problem...

There are use-cases where you have a completion handler that takes a Result argument, something like this:

func doSomeAsyncThing(completion: (Result<SomeSuccessObject,Error>)->Void) -> Void {
   ...
}

The pattern inside this function then can have a lot of error checking and staggered completion, something like this:

func doSomeAsyncThing(completion: (Result<SomeSuccessObject,Error>)->Void) -> Void {
   if let error = someErrorCondition {
         completion(.failure(error))
         return
   }
   if ...someOtherErrorCheck... {
         completion(.failure(someOtherError))
   } else if ...someOtherCondition... {
         if ...maybeMoreConditions... {
               completion(.success(successObject))
         } else {
               completion(.failure(someOtherError2))
         }
   }
}

This ends up being a little bit tedious... I've found when it gets large enough, it's sometimes more readable to wrap the whole thing in a do..catch block like so:

func doSomeAsyncThing(completion: (Result<SomeSuccessObject,Error>)->Void) -> Void {
   do {
          if let error = someErrorCondition {
                throw error
          }
          if ...someOtherErrorCheck... {
                throw someOtherError
          } else if ...someOtherCondition... {
                if ...maybeMoreConditions... {
                      completion(.success(successObject))
                } else {
                      throw someOtherError2
                }
          }
    } catch {
          completion(.failure(error))
   }
   completion(.success(AnotherSuccessObject))
}

What I'm wondering is whether this case warrants some additional syntactical help... Perhaps the introduction of a throwinto keyword (or something like it), to stand in for the do/catch lines. Something like:

func doSomeAsyncThing(completion: throwinto (Result<SomeSuccessObject,Error>)->Void) -> Void {
   if let error = someErrorCondition {
         throw error  // implies: completion(.failure(error)); return
   }
   if ...someOtherErrorCheck... {
         throw someOtherError
   } else if ...someOtherCondition... {
         if ...maybeMoreConditions... {
               completion(.success(successObject))
         } else {
               throw someOtherError2
         }
   }
}

This could do the same thing for functions that return a Result type:

func doSomething() -> throwinto Result<SomeSuccessObject,Error>

Of course throw can only have one target, so any given function can specify only one of either throwinto or throws, but not both and not more than one of each.

It doesn't seem like much but I think it helps to clarify intent and reduce some clutter in the code.

Thoughts?

With async/await it could just be this:

func doSomeAsyncThing() async throws -> SomeSuccessObject {
    if let error = someErrorCondition {
        throw error
    }
    if ...someOtherErrorCheck... {
        throw someOtherError
    } else if ...someOtherCondition... {
        let asyncResult = await someOtherAsyncFunction()
        if ...maybeMoreConditions... {
            return successObject
        } else {
            throw someOtherError2
        }
    }
    return AnotherSuccessObject
}

And then you would consume it like this:

func asyncCaller() async {
    do {
        let result = try await doSomeAsyncThing()
        // success!
    } catch {
        // Handle error
    }
}

Basically, all the annoyances of having to deal with completion handlers and Result types go away once we have async/await. You'll never want to write a function that takes a completion handler again.

2 Likes

Agree but I wish we had any approximate date or release version to expect it

1 Like

Note that this version can be rewritten using Result.init(catching:):

func doSomeAsyncThing(completion: (Result<SomeSuccessObject,Error>)->Void) -> Void {
   completion(Result {
       if let error = someErrorCondition {
           throw error
       }
       if ...someOtherErrorCheck... {
           throw someOtherError
       } else if ...someOtherCondition... {
           if ...maybeMoreConditions... {
               return successObject
           } else {
               throw someOtherError2
           }
       } 
       return AnotherSuccessObject
    })
}
6 Likes

The issue with these solutions is that they don’t work with typed errors :/

Oh, thanks for that! I'll have to see if that works in my use-cases. That may also help with some of the issues I had in ensuring completions get called in all code-paths (Distinguishing single-call closures vs multi-call closures).

Sorry, what do you mean by typed errors?

If the argument result of the completion was not Error but a specific conforming type, then throw couldn’t ensure a matching type

The solution is to declare a variable of the expected result type but instead of initialising it immediately, do it in the various code branches. The compiler will complain if you skip any code path and also if you try to initialise the result variable more than once.

func doSomeAsyncThing(completion: (Result<SomeSuccessObject, SomeEnumeratedError>) -> Void) -> Void {
    let result: Result<SomeSuccessObject, SomeEnumeratedError>
    defer { completion(result) }
    
    if let error = someErrorCondition {
        result = .failure(error)
        return
    }
    if ...someOtherErrorCheck... {
        result = .failure(someOtherError)
        return
    } else if ...someOtherCondition... {
        if ...maybeMoreConditions... {
            result = .success(successObject)
        } else {
            result = .failure(someOtherError2)
        }
        return
    } 
    result = .success(anotherSuccessObject)
}

@jayton and @prathameshk, I've used versions of both of your approaches in the past, but I think there's an important distinction to be made. In both of your snippets, the function isn't actually asynchronous: you still compute the result synchronously, only instead of returning it, you call the completion handler with it. If computing the result itself is an asynchronous operation, this approach would no longer work.

If this is indeed supposed to be a synchronous function, then I think using Result<_,_> as a return type instead of as a parameter to some completion block is a better idea, plus you get all the benefits where the compiler enforces that a value is returned on all branched through the function.

That’s true, but the OP’s proposal already depends on all the “actual work” happening synchronously (although perhaps deferred). As far as I can see, the pitched throwsinto doesn’t address this, it’s just sugar for wrapping a function in Result.init(wrapping:).

1 Like