[Pitch] Deferred catch syntax to minimize nesting in common error-handling

Motivation

It has recently been reaffirmed in SE-0143: Typed throws that throwing is considered Swift's native/preferred way of handling errors.

One relatively minor drawback, but still a persistent drawback nonetheless, is that when thrown errors are handled rather than propagated, it always necessitates additional nesting.

It is widely considered best practice nowadays to minimize nesting to improve readability and ease of reasoning through control flow. Indeed, many Swift language features, such as guard, aid in this goal of minimizing nesting. It seems unfortunate therefore for completely common-case error handling to always require further nesting.

Proposal

The proposal in this pitch is to add defer catch syntax that is considered identically equal to surrounding the rest of the execution scope with do, followed by the specified catch clause(s).

For example:

func foo() {
    do {
        // the happy path of this entire
        // function now has to be nested
    } catch {
        // render error
    }
}

becomes

func foo() {
    defer catch {
        // render error
    }

    // happy path is now unindented, in a similar fashion to
    // what we can typically accomplish for non-throwing code
}

Note that the ensuing catch clause(s) can be just as rich as regular catch clause(s), e.g.:

func foo() {
    defer catch let catError as CatError {
        // render cat error
    } catch let dogError as DogError {
        // render dog error
    } catch {
        // render other error
    }

    // happy path
}

Likely the main risk for confusion this could be argued to introduce is that to a certain degree, you need to see the code beneath to understand the implications of the catch clause. This is the main reason for the adoption of the existing keyword defer, which already denotes this concept. In normal usage of the word defer, you similarly need to mentally process code further below to understand the full implications of what code the deferred code follows.

Differences from earlier pitches

Somewhat related forms of this idea appear to have come up once or twice before, but have typically been a subset of much broader proposals that have resultantly been more complex and harder to reason about.

It is considered an important design principle of this proposal that it be treated identically equal to the existing, well-understood pattern of a single do block followed by catch clause(s). To understand the exact implications of code following this new pattern, one need only mentally convert it to the old pattern in a 1:1 fashion, not understand any new paradigms.

Super-brief discussion of alternatives considered

  • guard catch rather than defer catch
    • While it matches some of the intent of guard to keep the happy path unindented, the overall semantics are different enough (specifically that guard means code below will NOT be run) that it almost certainly leads to more confusion
  • Allowing catch clauses after the function definition itself (see below) is elegant in many ways, but by virtue of being so alien to pre-existing patterns, risks leading to much confusion (or mis-parsing to even well-trained eyes)
func foo() {
    // happy path
} catch {
    // render error 
}
15 Likes

With the second alternative approach, would there be a way for it to perform the equivalent to this:

func foo() {
    let x = 1
    defer {
        print(x)
    }
}