[Pitch N+1] Typed Throws

Hi all,

While there is an active discussion of typed throws over in this thread, the latest proposal is from a thread started in July 2020. We have an updated proposal with a revised design for typed throws, incorporating a number of the ideas and arguments that have come up over the years. I'm also working on an implementation of this design.

The basic idea is unchanged: functions and closures can specify a thrown error type, so that clients know what kind of error they need to handle when calling that function:

func callCat(name: String) throws(CatError) -> Cat { ... }

func feedKitty(name: String) {
  do {
    let cat = try callCat(name: name)
    cat.feed()
  } catch {
    print("Cat wouldn't come: \(error.catReason))") // error is of type CatError
  }
}

The introduction of typed throws has significant impact on the type system and standard library, all covered by proposal document.

Comments and questions greatly appreciated!

Doug

63 Likes

Just to clarify: would the following code generate a compile-time error?

func foo() throws(FooError) {}
func bar() throws(BarError) {}

func baz() {
    do {
        try foo()
        try bar()
    } catch let error as FooError {
    } catch let error as BarError {
    }
}
2 Likes

I think the motivation section could be much clearer on when typed throws are meant to be used instead of untyped throws, and vice versa. As written, it seems like it’s a mostly aesthetic choice (“Some developers are not satisfied”), which doesn’t really square with Swift’s clear stance so far that untyped throws are enough for the vast majority of cases. Either this is a repudiation of that stance, or it’s a carve-out meant for very specific situations like embedded. It would be good to have clarity on what the intent is here. (Using more realistic examples instead of callCat would also help here.)

It also feels to me like a more natural spelling would be throws SomeError rather than throws(SomeError). Is there a reason the parentheses are needed?

20 Likes

How would I change this fragment to use typed throws?

struct Payload {
    var payload: String
}

enum LoadError: Error { case unavailable, timedout }
func load() throws -> Data {
    ...
}

enum DecodeError: Error { case corrupted, incompatible }
func decode(_ data: Data) throws -> Payload {
    ...
}

func loadAndDecode() throws -> Payload {
    let data = try load()
    return try decode(data)
}

Like so?

enum LoadAndDecodeError: Error {
    case loadError(LoadError)
    case decodeError(DecodeError)
}

func loadAndDecode2() throws (LoadAndDecodeError) -> Payload {
    let data: Data
    do {
        data = try load()
    } catch {
        throw .loadError(error)
    }
    let payload: Payload
    do {
        payload = try decode(data)
    } catch {
        throw .decodeError(error)
    }
    return payload
}
Syntax optimisation tangent

Regardless, I think we could squeeze that into a much simpler equivalent:

func loadAndDecode2() throws (LoadAndDecodeError) -> Payload {
    let data = try load() catch throw .loadError(error)
    return try decode(data) catch throw .decodeError(error)
}

And then, if there's another layer on top:

struct Postprocessed {}
enum PostprocessError: Error { case postProcessFailure1, postProcessFailure2 }
func postProcess(_ payload: Payload) throws -> Postprocessed {
    ...
}
func prepare() throws -> Postprocessed {
    let payload = try loadAndDecode()
    return try postProcess(payload)
}

Would that require a nested enum?

enum PostprocessError2: Error {
    case loadAndDecodeError(LoadAndDecodeError)
    case postprocessError(PostprocessError)
}
1 Like

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