SE-0413: Typed throws

You don't need custom operator or macro for this as well, you can just add the following function to use fatalErrror after nil-coalescing operator:

func fatalError<T>(
    _ message: @autoclosure () -> String = String(),
    file: StaticString = #file, line: UInt = #line
) -> T {
    Swift.fatalError(message(), file: file, line: line)
}

It's being explicit about the thrown error type within the body of the do...catch block. The typed throws syntax lets you be explicit about the thrown error type for a function or closure, e.g.,

func f() throws(MyError) { 
  // ... this code will only throw MyError instances
}

but when you get into the body of a do...catch block, it's a completely different error context that could throw anything---in other words, it's effectively an untyped throwing context. @beccadax's suggestion lets us specify the thrown error type within that context.

Doug

14 Likes

And itā€™s different from inferring the error type from the catch blocksā€™ variable bindings because it doesnā€™t rely on backwardly propagating type system constraints from the catch leaves to the do block.

1 Like

How is that backward propagation? Or are we talking about a separate, new feature that allows untyped throws in do blocks to be given a type? In the typed throws scenario, the type of error should be immediately available from the tried function, regardless of what the catch block(s) say(s).

1 Like

The proposal as specified determines the caught error type by taking the union of all of the error types thrown in the do...catch body. The catch clauses aren't considered, so there is no backward propagation.

@beccadax 's idea doesn't change this; rather, it augments it with a syntax to specify the caught error type of a do...catch body.

Doug

3 Likes

But notice that this interpretation changes the keyword throws into the word "throw"ā€”that is, it changes the tense. That's a hint that it's the wrong way to read it.

Grammatically, throws is not imperativeā€”it's conditional. (Specifically, the zero conditionalā€”the one for factual if-then relationships.) In other words, throws is short for "if this code has an error, it throws". It's never a command or even an absolute promise; when that's what we mean, we use throw instead.

That's what the throws keyword means everywhere it's seen in Swift today, and it would be true for my proposed use, too. "If this do statement has an error, it throws."

13 Likes

we drift ever closer to do as implicit immediately-executed closuresā€¦

2 Likes

Sure, but it's subtle, both literally and conceptually.

I guess I think of this approach as lacking in type inference. I gather that it's simpler to implement, in the compiler? That's generally true of explicit typing vs type inference. Does it make it the right approach, though?

1 Like

Yeah that's another workaround, but the point is kind of the same: it would be nice to support this out-of-the-box:

let unwrapped = someOptional ?? fatalError("Expected this to be non-nil because...")

Without needing to copy workaround (of any kind) into every project.

As a general principle it is desirable to be able to provide explicit types, even when type inference is available, either because type inference does the "wrong" thing but can be overridden or because providing an explicit type will produce an error if someone makes a change that would have accidentally changed the inferred type.

9 Likes

What is your evaluation of the proposal?

Very much for; it is well thought-out regarding trade-offs and does not go ahead to implement more than can be implemented right now without too far-ranging consequences.

Is the problem being addressed significant enough to warrant a change to Swift?

Definitely. Though the original rationale for only supporting untyped throws did make sense in its own way, it is not a one-size-fits-all solution. The introduction of Result did much to undo the original Swift way of untyped errors whilst at the same time introducing an impedance mismatch between throws and Result as well as Result.init(catching:) and Result.get()

There are many cases where all the possible errors indeed can be clearly enumerated, such as formatting and parsing functions. The rationale that client code is made more resilient toward updates in errors throws by libraries is also a bit of a fiction as libraries may, at their leisure, not only add error cases, but also rename and remove old ones, breaking client code. This recently happened with PhoneNumberKit, I think.

Sometimes, I have even just wished I could restrict errors to a general error type such as any LocalizedError or RecoverableError, but not even that has been possible, complicating code or making it not worthwhile to implement.

Does this proposal fit well with the feel and direction of Swift?

Yes, mostly.

I am not crazy about the spelling of throws(Error) but will take it if it makes specifying the error thrown possible.

Likewise, I would have hoped that it would be possible to throw more than one error type and exhaustively catch them one by one in a catch block, as I have a habit of collapsing adjacent throwing function calls into one do block, but I take it that this proposal does not preclude revisiting that possibility at a later date.

If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?

Not exactly; if anything, I have used the opposite, where the catch or switch clause does not have to be exhaustive, so one may approximate typed throws but without the guarantees of exhaustiveness that make Swift use, and where the current state of catch is an Ā»exceptionĀ«, if you might.

How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

I have read the proposal in its entirety as well as this thread from top to bottom.

do throws is a syntax you could choose to use if you want to state the do statement's throwing behavior explicitly; you might use it to help the type checker, to communicate your error behavior more clearly to readers, or to ask the compiler to check that you're throwing what you expect to throw. But it would be purely optional. You could still use plain do, and the compiler would infer the throwing behavior as best it could.

(In the full context of the posts I was replying to, I thought this was obvious, but if you missed some of that context I can see how you might have misunderstood what I wrote as proposing something mandatory!)

4 Likes

Right, I think I misunderstood - I [had] thought you were proposing it as a solution to the "one error xor any Error" problem in the current proposal.

I've put up a pull request adding this syntax and its semantics to the proposal document. There are a few more details there that should help clarify what Becca's proposal means for typed throws.

Personally, I think this is an important addition---it means that all contexts in which an error is thrown can be explicitly typed, which is a good end state.

Doug

21 Likes

Excellent. I'm going to extend the review period for one week to allow people to comment on this.

The review period will now conclude on Thursday, December 7th.

3 Likes

I think it'd be helpful if the amended proposal could elaborate on if & why explicit typing of the error type is necessary (as opposed to inferring the type from the catch clauses and do-block contents).

It seems like an okay thing to add, in the sense that it's pragmatic and implementation-wise straightforward, and might be useful for code clarity on occasion - in the same way as explicit typing in general. And its addition doesn't preclude it becoming obsolete (in practice, if not formally) after type inference improvements in future.

Still, it'd be good to have the need for it spelled out a bit more.

If the uncertainty is unclear, start with [answering in lay-coder's terms] questions like: why aren't these two equivalent:

do throws(CatError) {
    try callCats()
} catch {
    ā€¦
}
do {
    try callCats()
} catch is CatError {
    ā€¦
}
3 Likes

Three questions regarding this amendment:

First, regarding this ruleā€”

If a throws-clause is present, then there must be at least one catch-clause.

Why this requirement? Could it not be useful for a non-catching do for the purposes of closure type inference?

Secondā€”

How do nested throws affect type inference? The original proposal writes of func callCat() throws(CatError) -> Cat:

The function can only throw instances of CatError. This provides contextual type information for all throw sites, so we can write .sleeps instead of the more verbose CatError.sleeps that's needed with untyped throws.

But suppose we have:

func callCat() throws(CatError) -> Cat {
  do throws(DogError) {
    throw .sleeps  // (1)
  } catch {
    throw .sleeps  // (2)
  }
  fatalError()
}

Am I correct in deducing that DogError.sleeps is thrown at (1), which is caught as an error of static type DogError and handled by rethrowing CatError.sleeps at (2)?

Thirdā€”

throws-clause not being part of this amendment (and hence not in the diff), I'm assuming this permits:

do throws /* note: no type */ { ... } catch { ... }

If so, does this "force box" the errorā€”i.e., is it equivalent to do throws(any Error)...ā€”or is it always equivalent to bare do { ... } catch { ... }?

Actually, it would seem to me that the latter wouldn't be logical, particular for nested type inference purposes. For instance, given:

// --- This all works in Swift 5.9 ---
struct E: Error { }
enum F: Error { case e }

extension E {
    static var e: F { F.e } // note this twist.
}

extension Error where Self == E {
    static var e: E { E() } // yes, I'm making your life difficult.
}

func e() -> Error { .e }  // returns a boxed `E`
func f() -> Error { F.e } // returns a boxed `F`
func g() throws { throw .e } // throws error of type `E`

// --- As originally proposed here in SE-0413 ---
func h() throws(F) { throw .e } // throws error of type `F`

func i() throws(F) {
  do { throw .e } // throws error of type `F`
}

Can you explain how the following behaves?

func j() throws {
  do throws(F) {
    throw .e // throws error of type `F`
  } catch {
    throw .e // throws error of type `E` (I think?)
  }
}

// --- What happens here? ---

func k() throws(F) {
  do throws(E) {
    throw .e // (5)
  } catch is E {
    throw .e // (6)
  }
  // ...
}

func l() throws(F) {
  do throws {
    throw .e // (7)
  } catch is E {
    throw .e // (8)
  }
  // ...
}

func m() throws(F) {
  do throws(any Error) {
    throw .e // (9)
  } catch is E {
    throw .e // (10)
  }
  // ...
}

func n() throws(F) {
  do {
    throw .e // (11)
  } catch is E {
    throw .e // (12)
  }
  // ...
}
5 Likes

A do without a catch doesn't introduce a new context in which the errors can be caught, so the body is still treated as being part of the enclosing context.

Yes, that is correct. The key consideration here is that the body of the do..catch statement in which the throws clause is relevant is what's in the {...} immediately following the do. The bodies of catch clauses are in the throwing context of whatever the do...catch is embedded in, which is the function itself in all of your examples.

Yes. throws-clause is defined as:

throws-clause -> throws thrown-type(opt)

so do throws is permitted.

This force-boxes the error thrown from the do body to any Error. throws is equivalent to throws(any Error) in other contexts, and that should remain true here.

Yes, you are correct. That extension Error where Self == E did, in fact, make my life more difficult. I'll be turning that into a compiler test case...

(5) throws E.e, (6) throws F.e.

(7) throws E.e via your evil extension, (8) throws F.e.

(9) throws E.e via your evil extension, (10) throws F.e.

(11) throws E.e via your evil extension, (12) throws F.e.

Doug

7 Likes

I feel like there's a general principle in the language that wherever there is inference for something, there's also a way to spell it explicitly. I could refer to that, I guess?

The latter is not correct code, though. You would need something like catch is CatError or catch let error as CatError. Regardless, the difference stated plainly is: the do throws(CatError) formulation makes sure that nothing in the do block throws anything other than CatError, while the latter allows the do block to throw anything, but has special processing when CatError is thrown.

Doug

1 Like

Eek, this is a sharp edge, then, because if I delete the catch clause, (11') would throw F.e, yes?:

func n_prime() throws(F) {
  do {
    throw .e // (11')
  }
  // ...
}
3 Likes