Un-nesting the happy path when catching errors

The motivation for this idea is that it would be nice if the happy path didn't need to gain a level of indentation just because we want to do some error handling. A solution that occurs to me is the following:

readFromDatabase() catch {
    tx.rollback()
}

One can attach a catch block directly to the end of a throwing expression (readFromDatabase() in this example is a throwing operation), and doing so also means that we don't need to write try on readFromDatabase(), since the control flow can no longer be broken by a thrown error.

It is debatable whether or not try should be required if an error is thrown from the catch block, e.g.:

try readFromDatabase() catch {
    try tx.rollback()
}

Without try it could look to the casual reader like try tx.rollback() is somehow contained and that the whole expression can't exit the containing function by throwing an error.

2 Likes

Oh, this post was a bit off the cuff, and thinking about it just a bit more I realize that my example function readFromDatabase() would presumably return a value, and it’s not clear how return values should be handled in my proposed syntax. So… this post has been demoted to “food for thought”

3 Likes

I do not understand the syntax or indeed what actually happens when try tx.rollback() (inside the catch block) fails in the second example.


As for the pitch itself it does look a shorthand to do { ... } notation so logically it could be something like this:

do try readFromDatabase() catch {
    tx.rollback()
}

(perhaps without do), instead of the current:

do { try readFromDatabase() } catch {
    tx.rollback()
}
1 Like

Yes, this is my idea - I started off thinking just as you did, and then I too realized that do is unnecessary, and then I realized that if all thrown errors are caught then we don’t need try either

1 Like

Perhaps it would be useful if do/catch were an expression as if/else` can be. For example:

let value = do {
  try makeValue()
} catch is TerribleFateError {
  throw AcceptableOutcomeError()
} catch {
  defaultValue
}

Would that resolve the issue?

7 Likes

Something similar can be achieved right now using Result:

let result = Result { try readFromDatabase() }
guard case let .success(value) = result else {
    tx.rollback()
    return
}

// Un-nested happy path with unwrapped value
print(value)

It might be a nice quality of life thing to consider adding some Optional unwrapping like syntax sugar for this though:

guard try let value = readFromDatabase() else {
    tx.rollback()
    return
}

print(value)

Might make error handling look nicer than it currently is?

2 Likes

Well currently you can do:

guard let value = try? readFromDatabase() else {
    tx.rollback()
    return
}

print(value)
1 Like

While that does work, it loses the actual error value from the function call since try? converts it to an optional, which isn’t ideal for error propagation.

2 Likes

I like this. It feels like it follows on naturally from the recent(ish) improvements to if and switch.

The current way of achieving this seems worse:

let name: String
do {
  name = try doThing()
} catch {
  // handle and throw error
}

print(name)
1 Like

You still need closures for too many use cases, making do/catch largely exactly as bad as if/guard.

E.g. given

func f(_: String) { }
var value: String { get throws { "" } }

…you need the do/catch in a closure:

f({ do { return try value } catch { return "" } }())

Similarly, you need if/else in a closure:

f({ if true { "" } else { "" } }())

…because the nice solution that seems like it ought to work, does not:

f(if true { "" } else { "" }) // ❌ 'if' may only be used as expression in return, throw, or as the source of an assignment

Yeah, I'd love to be able to use statement expressions in more places, but I think if that's even on the table then it'd have to be added in a new major version and staged in as an upcoming feature.