[Pitch N+1] Typed Throws

As your upper layer just throws without any type on it, it wouldn't need another nested enum, in fact, I think the purpose is not to create nested enums to encapsulate downwards thrown errors but use try ... catch statements in the immediately upper layer in order to eat the error and stop throwing if possible, but you can do typed throws if you think it makes sense to you, it all ends with a correct design.

1 Like

That’s covered in the proposal:

When throw sites within the do block throw different (non-Never) error types, the resulting error type is any Error. For example:

do {
  try callCat() // throws CatError
  try callKids() // throw KidError
} catch {
  // implicit 'error' variable has type 'any Error'
}

In essence, when there are multiple possible thrown error types, we immediately resolve to the untyped equivalent of any Error.

Would one be able to catch thrown any Errors from called APIs and “translate” them into concrete typed throws? e.g. If the Cat API had some remote cats, could I catch the URLErrors I might get from a remote request and throw a typed CatError instead?

If so, will the compiler know that consumers of my API thinking they could catch URLErrors for remote operations are wrong and warn them that only CatErrors will ever be emitted when they attempt to catch an impossible error?

1 Like

Yeah, that’s what I thought. I think it would be really unfortunate and kind of point-defeating, if it’s not possible to exhaustively catch the errors from a do-catch except in the easiest of situations.

5 Likes

I think the proposal should explicitly state whether catching exhaustively all the possible thrown errors lets you omit a final catch clause or not. It is somewhat inferred from the wording, but having to do this is counterintuitive:

do {
  try callCat() // throws CatError
  try callKids() // throw KidError
} catch let error as CatError {
} catch let error as KidError {
} catch {
  // 1. why is this catch-all still needed?
  // 2. will you get a warning about it being unreachable?
}
9 Likes

When we started on an implementation when the last pitch came up, we noticed that throws SomeError introduces some parsing ambiguities, because of contextual keywords. Here is an example that would be ambiguous to parse:

protocol Foo {
    func bar() throws
    mutating
    func baz()
}

Here we could either parse func bar() throws (mutating) and func baz(), or func bar() throws and mutating func baz(). I’m sure we could come up with some rules to disambiguate, but we thought at the time that it would be easiest to avoid ambiguities altogether. If more people called for the throws SomeError syntax though, it would be possible to rethink that for sure.

10 Likes

It doesn't feel right though. Consider this analogues example:

func foo() -> Foo {.... }
func bar() -> Bar { ... }
enum FooOrBar { case foo(Foo), bar(Bar) }
func fooOrBar() -> FooOrBar { ... }

Now imagine we do not have enums in the language, and to emulate them we just use Any:

typealias FooOrBar = Any
func fooOrBar() -> FooOrBar { ... }

and resort to "if let casts" on the caller side to disambiguate the two. It would do the job, but it doesn't feel right.

2 Likes

Overall, this feels great to me.

I think the decision to avoid tumbling down the sum type rabbit hole is correct, and I don't mind that "error handling case exhaustion checking" might not be as smart as it could. At least the rules are clear: It's either the one concrete type, or any Error.

While I welcome the possibilities it brings for avoiding existentials (eg: for embedded) and more expressive APIs, I am most excited about closing the gap to types like Result and Task and the generalizations it brings to their implementations.

What I am really, REALLY excited about is this:

public protocol AsyncIteratorProtocol<Element, Failure> {
  associatedtype Element
  associatedtype Failure: Error = any Error
  mutating func next() async throws(Failure) -> Element?
}

public protocol AsyncSequence<Element, Failure> {
  associatedtype AsyncIterator: AsyncIteratorProtocol
  associatedtype Element where AsyncIterator.Element == Element
  associatedtype Failure where AsyncIterator.Failure == Failure
  __consuming func makeAsyncIterator() -> AsyncIterator
}

Getting primary associated types on AsyncSequence (combined with an improved stream API) will be fantastic! Swift 6 world domination tour, let's gooo! ; )

In terms of syntax, I second this:

11 Likes

How "func" is different?

func bar() throws (func) ...

Is that because "func" is a reserved keyword and "mutating" is not? Could this be changed, e.g. promoting "mutating" to be a reserved keyword?

A slight +0.5 nudge for "throws SomeError" instead of "throws (SomeError)", but that's not a show stopper.

2 Likes

Yes, exactly.

That would be a source-breaking change (because right now both variables and types can be named mutating). Also there’s probably other contextual keywords with similar arising ambiguities.

5 Likes

Yeah, that’s one of the main selling points of this proposal for me too. Let’s hope that we can somehow get this into the Standard Library in an ABI-stable manner.

4 Likes

Looks good overall. Just a couple quick questions.

Will it be possible to call functions that throw a non-Never uninhabited types without using try despite them not being considered non-throwing in the type system?

Did you investigate whether throws E is viable in the grammar and if so did you consider omitting the parentheses in throws(E)?

You can find the rationale for sticking to throws(SomeError) here.

1 Like

Hmm, you are right, this is valid code now:

struct mutating {
    mutating func mutating() -> mutating { mutating() }
}

Could one tell upfront if the keyword in question is reserved or not looking up in swift grammar document, or how?

1 Like

There is a list of contextual keywords in the Swift Reference.

4 Likes

This should be in the alternatives considered, including the part that says it could be reconsidered if people feel strongly. Reviewers should be aware of that.

7 Likes

@Douglas_Gregor

This is only my hand-wavy take as I worked on the implementation of the parsing before. I certainly haven’t tested if that’s actually possible, so one of the authors of the pitch can maybe say more.

2 Likes

Agreed. This will unlock so much unrealised potential for ‘AsyncSequence’. Beyond the primary associated type benefits, no longer needing to define both throwing and non throwing source sequence variants, plus the ability to finally define a generic ‘AsyncSource’ type will really make it a much more expressive and versatile type.

6 Likes

While the rationale for softening the stance that untyped throws are all we need is not complete in this poposal, I agree with the reasons given by @John_McCall in Precise Error Typing. While it's unfortunate that the parenthesis are required to make the grammar unambiguous, I'd rather deal with some extra parentheses than an ambiguous grammar.

Overall +1.

2 Likes

not sure if this was discussed somewhere before, but what about:

func foo() throws<FooError>
// instead of 
func foo() throws(FooError)

at least at first glance it feels more type-related and less function-y to me...

1 Like