[Pitch] Introducing do-let-catch for Cleaner Error Handling

I recently encountered a scenario where I found the existing syntax in Swift unnecessarily complex and less readable:

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

I believe it would be far more readable and concise if we could write it this way:

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

Note how I didn't even have to specify the types of data and response and could profit from Swift's type inference, which isn't possible with today's code. It's also just 3 lines instead of 7.

Currently, Swift’s do-catch syntax requires both do and catch to be followed by full code blocks inside curly braces, similar to the traditional if-else structure. However, Swift introduced the guard-let-else construct, which improved readability and reduced boilerplate by replacing the if body with a more compact let expression. This was a significant step forward in making code more concise without sacrificing clarity.

Considering that unwrapping an Optional (which can fail) is conceptually similar to executing throwing code (which can also fail), it seems only natural to apply a similar simplification. When an Optional fails, it evaluates to nil, whereas when throwing code fails, it passes an error to the catch block. Given this similarity, I propose extending this idea to do-catch by introducing a do-let-catch syntax.

This new syntax would allow us to write a let expression after the do, similar to how guard-let-else works. It would allow for multiple let expressions separated by commas, like so:

do let x = try foo(), let y = try bar() catch {
   // handle error, must return or throw like in a `guard` block
}

// x and y are available here

This approach would not only make code more compact but also more aligned with Swift’s design philosophy of clarity and expressiveness. It feels like a natural progression that has simply been overlooked so far.

What are your thoughts on this potential improvement?

17 Likes

Have you seen Ben Cohen's pitch?

1 Like

I recently had to write some code with mixed guard blocks and do blocks and it left me really wanting something like this, but I’d argue there need to be a variant that forces early exit (like what you propose) and one that doesn’t. If you are reusing the existing do/catch keywords then that should be the variant that does NOT force early exit, to match existing semantics.

Other than the bikeshedding, heck yeah, we need this!

@xwu I just read the proposal, but I don’t understand how the introduction of a “then” keyword improves my above code example. How would its acceptance improve the code, could you give an example?

The proposal doesn’t seem to aim for what I’m aiming for, it seems to be about improving expressions, my suggestion is about improving error handling and fixing a hole in the current syntax making do-catch inconsistent with if-else/guard-let.

@Nathan_Gray I thought about a variant that doesn’t force an early exit, but isn’t that the do block already? I mean, a “do” handles code that could throw an error. If you want to handle it, you HAVE to early exit if it fails. If you wanted to ignore errors, you could use “try?” already without the need to change any existing syntax.

Or maybe I’m misunderstanding what you mean, in that case, if you could provide a code example, I might be able to better understand your point.

The current syntax allows you to say this:

let data: Data?
let response: URLResponse?
do {
   (data, response) = try await URLSession.shared.data(for: request)
} catch {
   data = nil
   response = nil
}

Why should we forbid that for the more consise case?

To elaborate a little, perhaps something like a catch-operator would work a little better. I haven't tested this yet, but I imagine it would look something like this:

infix operator !!
func !! <TResult, TError: Error>(
  lhs: @autoclosure () throws -> TResult,
  rhs: @autoclosure () throws(TError) -> TResult
) throws(TError) -> TResult {
  do {
    return try lhs()
  } catch {
    return try rhs()
  }
}

(!! is of course a placeholder name and it could have some other name, perhaps even a keyword.)

1 Like

How about just extending Result a bit? (not sure I get signature right for a generic use case, but that seems to compile in isolated context)

extension Result {
    typealias AsyncBody = @isolated(any) () async throws(Failure) -> sending Success

    init(cathingAsync body: sending AsyncBody) async {
        do {
            let value = try await body()
            self = .success(value)
        } catch {
            self = .failure(error)
        }
    }
}

Then the desired behaviour can be achieved using respective initializer (quite similar to Rust at this point):

import Foundation

struct CustomError: Error {
    let underlyingError: any Error
}

let (data, response) = try await Result {
    try await URLSession.shared.data(from: URL(string: "swift.org")!)
}
.mapError { error in CustomError(underlyingError: error) }
.get()

We might be missing such initializer anyway. And as an upside, this doesn't require any new syntax introduction.

2 Likes

With the do expressions proposed, you'd write:

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

There have been previous threads on guard + try/catch which is equivalent to OP.

https://forums.swift.org/search?q=guard%20try

The language has implemented other syntactic brevities with (imo) less motivation and/or more pushback. A feature like this that has seen some support over many years and is instead received with other syntax tricks, but I digress.

2 Likes

Who said something about forbidding this syntax? Any existing syntax should continue to work. I'm only suggesting to introduce a new option. You can still write above code then if you want to work with Optionals.

If that's gonna be possible with the pitch, then it would totally solve my problem. But it's not mentioned anywhere in the proposal. The only examples I can find return the same type from the catch block or use the then keyword. But I'm gonna mention my use case in there, thank you for the pointer.

It's mentioned here, under "Motivation":

And here, under "Detailed Design"

4 Likes

The last bullet point is the one that allows for throwing out of the catch block when returning a value from the do block.

2 Likes

Thank you for the detailed explanation where one can find the mentions of do expressions. But your long quote and need to explain where one can find the mentions and the lack of an example with a throw inside the catch show that the proposal aims at other improvements and do expressions are really a very small side aspect of the pitch.

Which is not to say I would be happy to see that pitch getting accepted. But it's also only in a pitch phase right now, and I believe there is still room for this pitch as well. It's far narrower and even if do expressions get accepted, I still personally prefer my suggested syntax simply because it's in line with guard-let.

I do understand that this pitch wouldn't be very important anymore if the language had do expressions. But it doesn't have them right now. And we don't know if the other pitch will ever leave the pitch phase. In case it doesn't, this could be an alternative.

Your premise feels overly reductionist. You can currently do this without "ignoring errors" via:

guard let (data, response) = try? await URLSession.shared.data(for: request) else {
    fatalError("or return or throw or break or whatever")
}

Yes, you lose the original error context in this shortened format, but this is as far as I could tell a language design decision. For better or worse, Swift errors are not Java exceptions.


(edit) To my mind, since error is already bound within a catch block that doesn't explicitly bind it to some other name, the easiest and most consistent way to add this to the language is to bind error in the else block of a guard that includes try?

3 Likes

So what you're suggesting is instead of introducing a new do-let-catch syntax, to alter the existing guard-let-else syntax when a try? is part of the expression and then provide an implicit error inside the else block?

That sounds like you agree with my general request but you're just preferring a different syntax. I could probably get used to your suggested syntax, but to me it seems to be less consistent because you're changing the meaning of an else block, now having an error parameter passed to it, which is a big semantic change. In my suggestion, we keep a catch block which already is known to have an implicit error or allowing an explicit name if desired.

But I disagree that your code example "doesn't ignore errors", it does exactly that because you lose the error in the else block as of today which means I can't handle the error, I can only ignore it and react the fact the "something" went wrong. I wouldn't consider that "handling", at most it would be "reacting blindly".

If you look closely to my code example of my original post, you will see that I am in fact throwing an error that still contains the original error, so I need it:

throw RequestError.failedToLoadData(error)

Well, personally I don't see try ... catch as being all that different from guard ... else and in fact your proposal requires that catch jumps out of the current scope, a.k.a. guard ... else semantics:

enum RequestError: Error { case failedToLoadData(any Error) }
func test(request: URLRequest) async throws {
    let data: Data
    let response: URLResponse
    do {
       (data, response) = try await URLSession.shared.data(for: request)
    } catch {
        print("trust me, I handled it") // instead of throw RequestError.failedToLoadData(error)
    }
    print(data, response) // compile error: constant `data` used before initialized
}

Nor do I agree with your general request. I simply stated the fact that you can get about 90% of the way there with existing syntax, and if the last 10% is absolutely necessary (I don't believe it is) then the path of least resistance probably looks something like that. Not that I recommend it either, I believe that implicitly binding error into catch blocks was probably a mistake, but here we are.

Instead what I'm suggesting is that you consider what you're trying to accomplish and how does "dressing" an error into a different type of error that includes the former is helping you in that regard.

From where I'm standing, I see the following possibilities:

  1. You want to handle a specific type of error, such as bad https certificate. In that case, the sugar around do / catch is probably not the limiting factor
  2. You want to handle a generic class of error, such as CouldNotLoadData; in this case, you don't really care if it failed because the dns is misconfigured or the user moved away from wifi, and try? is likely the shortest of your options
  3. You want to handle a specific type of error, just you don't want to handle it here: in this case, you mark your function as throws and use try which is equivalent to guard ... else { throw(error) }
  4. It is possible that you may want a combination of the above, but it's rather rare in practice. One scenario that comes to mind is having two http requests (e.g. one to get a session token and another one to run a rpc call) and in that case, if you can't handle errors locally , you may want different rules for dealing with request1Failed(with: baseError) and request2Failed(with: otherbaseError). In this case, do / catch is almost certainly what you want

Realistically, you probably want the original error because you want to log it or show it to the user in a dialog in which case, yeah it's annoying to do that because neither of those scenarios constitutes "handling" it.

I’m not here to debate whether the code I want to write makes sense for everyone. People have different approaches to error handling, and that’s totally fine. I have my own coding style, which is strongly influenced by Swift’s design, allowing me to write what I consider to be smart, maintainable code with detailed error information. This helps me minimize maintenance issues in the long run.

The introduction of typed throws in Swift 6 has been a significant improvement. It finally allows me to specify exactly what can go wrong in certain APIs, like network calls where multiple potential failure reasons need to be handled differently. The code example I originally posted is one way my network call could fail, and I want to return a specific type of RequestError (an enum). Since there are multiple reasons why the network call could fail, I want to ensure that these details are passed on, allowing my error handling code, which lives elsewhere, to manage common failures in a more nuanced way.

I’ve encountered this kind of syntax often, and it reminds me of the if-else situation, where guard-let enhanced both the writing and readability of code, improving the clarity of the language. I appreciate feedback, but I find it less helpful when people suggest what I “probably want to do” or say that what I’m asking for isn’t “absolutely necessary.” It is necessary for my use case, and at least 16 others have agreed (based on the likes in this thread). Not everyone needs to use every feature of the language, and that’s okay. But the language should be flexible enough to accommodate different needs. If a feature isn’t essential for you, there’s no obligation to use it—but that doesn’t mean it isn’t valuable to others.