Guard-Let-Catch (and If-Let-Catch) to avoid long (nested) do-blocks

I want to revive a discussion that was started 7 years ago in this thread. I find myself often writing long do blocks when calling into throwing APIs, effectively nesting much more code than actually needed, leading to the catch clause being very far away from the actual code that could have caused its entrance. For example:

func randomMovies(genre: Genre, count: Int) -> [Movie] {
   do {
      let movies = try Database.loadMovies(byGenre: genre)
      
      // no throwing API calls from here on...
      guard !movies.isEmpty else { return [] }

      var randomMovies: [Movie] = []

      for _ in 0..<count {
         randomMovies.append(movies.randomElement()!)
      }
   
      return randomMovies
   } catch {
      return []
   }
}

Likewise, I might want to provide different catch clauses for different throwing functions. Currently I have to provide nested do-catch statements to achieve that, which leads to less readable and less concise code very quickly.

I'm proposing to introduce a catch clause added to if or guard statements that is only usable and is even required if the if/guard condition includes a try call. For example, the above code could be written like this instead:

func randomMovies(genre: Genre, count: Int) -> [Movie] {
   guard let movies = try Database.loadMovies(byGenre: genre)
   catch { return [] }

   guard !movies.isEmpty else { return [] }

   var randomMovies: [Movie] = []

   for _ in 0..<count {
      randomMovies.append(movies.randomElement()!)
   }
   
   return randomMovies
}

This could also keep support for Optional types with else like suggested by Chris Lattner here:

I could also imagine that we keep the questionmark for try?, I've not thought about the syntax extensively. But I really want the advantages of a guard let for throwing APIs:

  1. Multiple of them with their own catch blocks like I can provide multiple guards with else blocks
  2. Avoid nesting for code that follows the guard as there's no throwing API involved there anymore

What do you think? Or was there any reason this was not further elaborated?

17 Likes

You can wrap only throwing part (which is usually preferable anyway):

func randomMovies(genre: Genre, count: Int) -> [Movie] {
   let movies: [Movie]
   do {
      movies = try Database.loadMovies(byGenre: genre)
   } catch {
      movies = []
   }   
   // no throwing API calls from here on...
   guard !movies.isEmpty else { return [] }
   var randomMovies: [Movie] = []
   for _ in 0..<count { randomMovies.append(movies.randomElement()!) }
   return randomMovies
}

Or even extract it as separate method.

2 Likes

It could be the case. For example, when you have methods that read from some data storage, in general you want them to throw an error, while occasionally there you might not care if there an error at all or in case of some errors. In last scenario extracting read as separate method would be even more better.

Assume, the data API throws errors like this:

enum DataError: Error {
    case forbidden
    case notFound
}

In that case you might want to propagate .forbidden case, and return some default value (like empty array) if nothing were found.

So, the method here looks better:

func getMovies(genre: Genre) throws { 
    do {
        return try Database.loadMovies(byGenre: genre)
    } catch let dataError as DataError {
        switch dataError { 
        case .notFound: return []
        default: throw dataError
    }
}

This is great idea for the future, and might be the case for initial question, while I would prefer extracting throwing part anyway

Without knowing the use case, how can you say that it's not a good idea to return an empty array?
It always depends on the use case and I don't want to elaborate the example further because it's just an example, not the main point I want to make. I should have chosen a better example though.

I would love to see do expressions. It would be consistent with the if-switch expressions. But that doesn't mean I don't additionally want guard-catch clauses. Which would be consistent with guard-else clauses.

These things are not the same. They express slightly differing intents and I would love to see both.

5 Likes

One of the reasons why I love this pitch is that it brings the error handling much closer to the throwing call. In the scenario where you may have multiple throwing calls inside of a single do/catch block, this really starts to shine.

func doWork() -> Int? {
    do {
        let a = try getA()
        let b = try getB()
        return a + b
    } catch {
        if let aError = error as? AError {
            print("Failed to get a: \(aError)")
        } else if let bError = error as? BError {
            print("Failed to get b: \(bError)")
        } else {
            print("This shouldn't happen")
        }
        return nil
    }
}
func doWork() -> Int? {
    guard let a = try getA() catch {
        print("Failed to get a: \(error)") // Assuming that `error` is available here?
        return nil
    }

    guard let b = try getB() catch {
        print("Failed to get b: \(error)")
        return nil
    }

    return a + b
}
6 Likes

The current syntax allows for this:

func doWork() -> Int? {
    let a, b: Int

    do {
        a = try getA()
    } catch {
        print("Failed to get a: \(error)")
        return nil
    }

    do {
        b = try getB()
    } catch {
        print("Failed to get b: \(error)")
        return nil
    }

    return a + b
}

Maybe it's just me, but declaring the uninitialized variables upfront coupled with the do blocks always feel really heavy-handed. I think there is still enough "nice to have" motivation to consider looking into guard-catch statements.

8 Likes

I assume the catch clause for guard would have the same return-or-die requirement that the else clause does, which I think would be an advantage over do. It's a strong indication to the reader that an exception at this point, even if caught, means the function cannot continue.

9 Likes

Also, it seems to me that what we're really talking about adding is guard-catch (and guard-else-catch), with guard-let-catch just coming naturally from that.

It sounds good to me, though I'm less sure of doing the same for if.

5 Likes

It’s not necessarily about repetition, more that the alternate path that we take (clear through the return) is right next to the call site of what threw. With the do block, the path is split into two halves that can end up pretty far from each other. This is (part of) the same motivation for guards to begin with. Short circuit once you can’t proceed and do it upfront where it’s easy to see all in one place.

5 Likes

This always bothered me about do blocks too. I support this proposal and think it would add some much needed ergonomics to what I understand is the preferred form of error handling these days. Allowing a variable to be declared and initialized on one line is reason enough, without all the other benefits OP mentions.

2 Likes

I don't see how do expressions could be used here (once we have them):

func randomMovies(genre: Genre, count: Int) -> [Movie] {
   let movies = do {
      try Database.loadMovies(byGenre: genre)
   } catch {
      return [] // error, can't return from here
   }
   ...
}

In this particular case we could simply write:

   guard let movies = try? Database.loadMovies(byGenre: genre) else { return [] }

but in more complicated examples it won't be possible (e.g. if you want to print the error).

2 Likes

A bit heavy, don't you think? Maybe something radically shorter like this?

guard let movies = try Database.loadMovies(byGenre: genre) catch {
    print(error)
    return []
}

as a shorthand for a longer:

let movies: [whatever the type is here]
do {
    movies = try Database.loadMovies(byGenre: genre)
} catch {
    print(error)
    movies = []
    return []
}
1 Like

@tera That is exactly what I suggest in the pitch.

3 Likes

Yep. Possibly unrelated to guard?

1 Like

I disagree in that guard indicates that what comes next must pass or the attached alternate path must short circuit. In that sense, I think it's integral and has a much higher chance of making it past the pitch phase than any of the other suggested alternatives.

// Reader sees guard, knows the expression must pass...
guard let result = try workThatCanFail() catch {
    // Otherwise we have to short circuit from here
}
// ...in order to reach this point
1 Like

I'm with @anon9791410 here, to me this feels an opportunity of syntax optimisation of a single expression cases rather than something related to "if/guard let" specifically. Bike shedding possible optimisations with single expression being on one or both branches:

// single expression on both branches
if condition { one() } else { two() }            // current syntax
if condition then one() else two()               // possible syntax A
if condition do one() else two()                 // possible syntax B

// single expression on first branch
if condition { one() } else { two(); three()  }  // current syntax
if condition then one() else { two(); three()  } // possible syntax A
if condition do one() else { two(); three()  }   // possible syntax B

// single expression on second branch
if condition { one(); two() } else { three() }   // current syntax
if condition { one(); two() } else three()       // possible syntax

// single expression on both branches
do { try one() } catch { two(error) }            // current syntax
try one() catch two(error)                       // possible syntax

// single expression on first branch
do { try one() } catch { two(error); three() }   // current syntax
try one() catch { two(error); three() }          // possible syntax

// single expression on second branch
do { try one(); try two() } catch { three(error) } // current syntax
do { try one(); try two() } catch three(error)     // possible syntax

Hi!
For some time I have missed more specific treatment in Guard-let for cases where we have more than one statement to be evaluated. When an error occurs, it is not possible to know which specific statement caused the problem. Then I found this post commenting on improvements to error capture in Guard-let. I believe the syntax below would be very interesting to use:

        // New concept
        guard {{statment_one}},
              {{ statment_two }},
              {{ statment_three }},
              {{ statment_n }} catch {
            debugPrint("Specify that statment causes error: " \(error))
            return
        }
        
        // Keep old concept
        guard {{statment_one}},
              {{ statment_two }},
              {{ statment_three }},
              {{ statment_n }} else {
            debugPrint("anyaway error handler")
            return
        }

I just wrote this code and really hate the unnecessary and unreadable syntax:

let data: Data
let response: URLResponse
do {
   (data, response) = try await URLSession.shared.data(for: request)
} catch {
   throw RequestError.failedToLoadData(error)
}

I would really prefer to write this instead, like suggested in my original post:

guard let (data, response) = try await URLSession.shared.data(for: request) catch {
   throw RequestError.failedToLoadData(error)
}

If people don't like the guard keyword being reused here, we could use do:

do let (data, response) = try await URLSession.shared.data(for: request) catch {
   throw RequestError.failedToLoadData(error)
}

So we would basically introduce a do-let. What do you think about this?

1 Like