SE-0413: Typed throws

Hello, Swift community!

The review of SE-0413: Typed throws begins now and runs through Thursday, December 7th, 2023.

Reviews are an important part of the Swift evolution process. All review feedback should be either on this forum thread or, if you would like to keep your feedback private, directly to me as the review manager by email or DM. When contacting the review manager directly, please put "SE-0413" in the subject line.

Trying it out

If you'd like to try this proposal out, you can download a toolchain supporting it:

You will need to add -enable-experimental-feature TypedThrows to your build flags to use the feature.

These toolchains do not implement thrown type inference for closures, so if you want a closure to have a specific thrown error type, you need to write it out as, e.g., { () throws(MyError) in … }

What goes into a review?

The goal of the review process is to improve the proposal under review through constructive criticism and, eventually, determine the direction of Swift. When writing your review, here are some questions you might want to answer in your review:

  • What is your evaluation of the proposal?
  • Is the problem being addressed significant enough to warrant a change to Swift?
  • Does this proposal fit well with the feel and direction of Swift?
  • If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?
  • How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

More information about the Swift evolution process is available at:

https://github.com/apple/swift-evolution/blob/main/process.md

Thank you,

Steve Canon
Review Manager

47 Likes

The rationale for needing parentheses does not seem to hold up. The first example given is impossible ((E) -> I cannot confirm to Error) and the second example is purely speculative, invoking a feature that not only doesn’t exist but isn’t currently planned to.

3 Likes

Whether (E) -> I conforms to Error or not isn't up to the parser. As the proposal states, baking into the language grammar the status quo that functions can't conform to any protocols—even by magic—is certainly possible, but it complicates the grammar and has the disadvantage of, well, baking in this status quo.

The second example isn't exactly speculative: We already have another effect called async, but if you want to make life painful for yourself and/or your users, Swift allows you to declare struct async: Error { }.

Currently, we disallow (arbitrarily) func f() throws async in favor of async throws. However, if typed throws did not require parentheses, then func f() throws async would become valid Swift, and it would mean a synchronous function that throws an error of type async. This would be the case whether or not you declare such an unhinged type, because it's not up to the grammar what types you declare or not so long as it's a valid identifier.

(We could, of course, say that all effect names must start with ASCII lowercase and that Error-conforming types must not start with ASCII lowercase—but that is technically source-breaking and, again, complicates the grammar.)

18 Likes

Admittedly, the compiler could detect this and essentially just ban it, by throwing an error a la "Cannot determine if you meant to make this function async or throw the Error type async (you should probably rename your async struct)".

But there are other potential benefits of the parenthesis, like readability through compartmentalisation. e.g. for throws(some NetworkError), let-alone for less trivial types like throws(some NetworkError & LoggableError & ChainableError).

2 Likes

Somewhat minor point, but I didn't see this explicitly mentioned. A throwing protocol property requirement will also be able to specify a type? I.e.

protocol Foo {
  var bar: Bar { get throws(BarError) }
}
9 Likes

When a function signature uses a generic parameter or associated type as a thrown type, that generic parameter or associated type is implicitly inferred to conform to the Error type. For example, given this declaration for map:

func map<T, E>(body: (Element) throws(E) -> T) throws(E) { ... }

the function has an inferred requirement E: Error.

Is that for some subtle functional reason, or purely for convenience?

If the latter, I'm not sure it's wise (genuinely - maybe it's fine, but something about it makes me pause). The thrown type parameter can appear in other places in the function declaration. It could be possible for someone to fixate on the parameters, glossing over the throws declaration hanging off the end, and not realise that there's an inferred Error conformance constraint on the type parameter.

That said, this inference already applies for e.g. Result:

func execute<E>() -> Result<Void, E> { … } // okay in Swift 5.9

…so I guess that ship has sailed, in essence. Maybe worth just explicitly noting this precedence, in the SEP?

Result's init(catching:) operation translates a throwing closure into a Result instance. It's currently defined only when the Failuretype is any Error, i.e.,

init(catching body: () throws -> Success) where Failure == any Error { ... }

Replace this with an initializer that uses typed throws:

init(catching body: () throws(Failure) -> Success)

The new initializer is more flexible: in addition to retaining the error type from typed throws, it also supports non-throwing closure arguments by inferring Failure to be equal to Never.

But is that a feature or a bug? As the proposal subsequently points out, that will let you write e.g.:

func randomNumber() -> Int {
    4 // https://xkcd.com/221
}

let result: Result<Int, Never> = .init(catching: randomNumber)

It seems potentially confusing, at least, to allow 'catching' from a closure / function argument that can't actually throw. The compiler doesn't allow similar cases like using the try keyword on a statement that doesn't throw (for good reason, IMO - it suggests a misunderstanding of something or mistake - e.g. wrote the wrong function name - by the code author).

It might select a non-throwing overload - the wrong overload, most likely, given the context - because technically that matches because it throws(Never)… unless the compiler is going to handle that specially?

You can already pass non-throwing closures to throwing-closure parameters, so this doesn’t make things any worse. It just makes the spelling more explicit.

8 Likes

Therefore, each rethrows operation in the standard library should be replaced with one that uses typed throws to propagate the same error type. For example, the Optional.map operation would change from:

public func map<U>( _ transform: (Wrapped) throws -> U ) rethrows -> U?

to

public func map<U, E>( _ transform: (Wrapped) throws(E) -> U ) throws(E) -> U?

This is a mechanical transformation that is applied throughout the standard library.

The pitch largely overlooks the key semantic of rethrows: that the function can only throw if and when its arguments do, and that the compiler enforces this in the implementation of that function. It mentions it only partially and only at the very end (and not even in the main proposal, but under 'Alternatives considered').

I'm not sure if it's intentional or not, but the pitch reads as having a pretty obvious subtext that it wishes rethrows were removed entirely. It almost says exactly that at the very end.

If it is in fact the proposal's intent that rethrows ultimately be removed (even if not by this specific proposal), I suggest it just say that. That's not the sort of change that should be snuck in.

In any case, leaving rethrows dangling isn't great. With the proposal as it stands, rethrows is in a somewhat crippled state because as much as the proposal says rethrows can be almost entirely replaced, I don't think that's wise, and without support for typing, rethrows becomes a barrier to adoption of typed [re]throws.

Even if it's essentially the same to callers of rethrowing functions, I think the implementors of those functions still care for the rethrows behaviour re. enforcing intended logic. Or at least I do. I consider it a step towards explicitly pure functions; more generally, for removing unintended side-effects. In this case, accidental error situations that aren't actually caused by the closure arguments (as rethrows today means and [mostly] guarantees).

1 Like

Right, but I guess what I'm thinking is that this shouldn't be held up as good behaviour, more just an existing oversight. And it's okay for the proposal to say "we're following the established conventions [even though they're not ideal, but that's a subject for a different proposal]".

Is there any workaround, with typed throws? e.g. can you replace throws(Failure) with something that means actually throws something; any error type but Never?

It's not a big deal, to be clear. I just think it's an unfortunate way to permit coding mistakes, that other parts of the language are already better at preventing (e.g. try, as noted).

That ship sailed long ago:

func f<K, V>() -> [K: V] { [:] }

struct S { }
f() as [S: S]
// Compiler error:
// Type 'S' does not conform to protocol 'Hashable'
// Global function 'f()' requires that 'S' conform to 'Hashable'
8 Likes

Overall, this looks fantastic and I'm very glad that this finally has the chance to become a part of the language.

I have one minor complaint though:

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 . We will refer to this notion as a type function errorUnion(E1, E2, ..., EN) , which takes N different error types (e.g., for throwing sites within a do block) and produces the union error type of those types. Our definition and use of errorUnion for typed throws subsumes the existing rule for untyped throws, in which every throw site produces an error of type any Error .

While I'm totally fine with the rule that multiple differently typed errors in a do block just produce an any Error in an unconstrained catch, I wonder why it isn't possible to exhaustively catch the errors like so:

do {
    try callCat() // throws CatError
    try callKids() // throws KidError
} catch is CatError {
    print("Error was CatError")
} catch is KidError {
    print("Error was KidError")
} // no catch-all needed because catch clauses are exhaustive

The proposal has the following note about this:

Note : Exhaustiveness checking in the general is expensive at compile time, and the existing language uses the presence of an unconditional catch block as the indicator for an exhaustive do...catch . See the section on closure thrown type inference for more details about inferring throwing closures.

Can someone explain what's so expensive about exhaustiveness checking? In principle we would just have to loop over every error thrown in the do block and look if it is convertible to one of the catch clauses, no? I cannot imagine why this should be any harder than exhaustiveness checking for switch statements/expressions.

If there is a way to make catch clauses exhaustive without having to have a catch-all clause that would be fantastic.


Definitely. The lack of typed throws was always one of the major pain points for me when using Swift.


IMHO, yes. The syntax feels intuitive, it will bring throws back to feature parity with Result/Task and will hopefully improve the standard library and especially the concurrency API in the long run.


I have used checked exceptions in Java before, but it feels like these are playing in a different league. I think, it's positive that the default in Swift will still be untyped errors, unlike Java where you either have exceptions that you don't have to declare at all, or the checked ones which have to be typed for every function that uses them. Swift seems to be striking a good balance there.

Honestly, the typed throws system in Swift feels very much like nice sugar around the error handling system in Rust, which I already really like.


I read and participated in basically all discussion and pitch threads regarding this topic, carefully read this proposal as well as some of its earlier revisions, and even started to work with some others on an implementation for this feature a while ago (even though we didn't really get far due to lack of compiler knowledge and time).

9 Likes

What is your evaluation of the proposal?

This is an excellent proposal, and I fully support it with a strong +1.

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

Yes! It fills a major hole between the loss of information between types such as Result and Task and the Swift's error-handling system.

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

It provides a nice abstraction over carrying a generic type parameter throughout throwing effects, which eliminates the need for previous workarounds like rethrows and @rethrows protocol annotations. Additionally, it opens doors for AsyncSequence to FINALLY introduce primary associated types now that we have a notion to express a generic Failure type.

The only thing I miss from subtyping relationships (or from future directions) section is that
we could accommodate subtyping the relationship of f() -> Element? and f() throws(Void) -> Element, as brought up by Xiaodi in the pitch thread.

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

Previously relying on Result types in Swift for internal error type information in our SDK was cumbersome, particularly during type conversion and unwrapping. Most other strongly types languages I used deal with similar Result constructs (e.g. Rust). What stands out in this proposal is the ability to consistently use multiple try statements within function bodies while preserving static error type information.

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

I closely monitored the proposal's evolution and its discussion history, as well as Doug's pull requests throughout the process. Moreover, I had the opportunity to test it with the provided toolchain.

1 Like

What is your evaluation of the proposal?

I am a strong +1 on this proposal. Typed throws are going to provide a new tool to developers to express their APIs and I am glad we are finally filling this hole. The concern around adopting typed throws in public facing APIs is valid and the proposal rightly calls it out but in the end the authors of public APIs need to decide what language tools they want to use.

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

In my past, I have written large scale iOS applications that either implicitly constrained the thrown errors to a specific type and expected everything to wrap their errors in this type or used libraries such as Combine that already allowed concrete error typing.

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

I have been actively following the proposal and have been working with @ktoso and @Douglas_Gregor on a follow up proposal for adopting it in the Concurrency library. While writing this follow up proposal I realised that typed throws will actually make a lot of APIs easier to spell and remove a lot of duplication from the current implementations.

5 Likes

+1 This looks good to me.

A big +1. This is an excellent proposal. I appreciate its technical care. I appreciate its attention to language ergonomics. I appreciate its motivating examples and thoughtfully stated rationale. And I especially appreciate how carefully it translates that rationale into recommendations about ongoing best practice.

Some SE proposals are “Let’s see what people do with it!” proposals. Not this one. Swift developer norms — culture and habit — are going to be a crucial part of making this new feature play out well in practice, and it’s good that this proposal maps out what those norms should look like as a part of making its case. I’ve spent serious time living with Java's unhappy attempts to make error types explicit, and I do not relish a return to the world of throw MyMeaninglessWrapperException(​exceptionThatIsMeaninglessToMe). But that unhappy world is a product of how the language gets used, not the feature itself. The proposal puts it beautifully:

Errors are generally propagated and rendered, but rarely handled exhaustively, and are prone to changing over time in a way that types are not.

With that in mind, I appreciate and wholeheartedly agree with the When to use typed throws section. I hope those thoughts make it into both official Swift language documentation and general Swift developer parlance. Those guidelines answer my concerns about repeating the mistakes of the Java model.

As far as the language design substance of the proposal, everything seems entirely spot on. (The subtyping rules make me slightly nervous, but they’re clearly necessary and I can’t spot any problems.)

I particularly appreciate the way this addresses generality gaps in rethrows, a Swift language feature that’s always had a bad smell for me. This proposal fully addresses my concerns with it. (I wonder whether we might deprecate rethrows entirely in the future. Maybe. Let’s see how things play out.)

Nice work!

A few specific thoughts:

  • I do not share the concerns expressed by others about the parentheses. The throws(Oops) syntax reads just fine to me. Even if it weren’t for the compiler parsing problems, in casual experiments, dropping the parens if anything makes human parsing a bit harder to my eye — especially when the error type is not a simple type. The proposed syntax is good as is.

  • This is probably going to cause large headaches for a small number of people, both on the Swift 5 and the Swift 6 sides of the wall:

    To prevent this from becoming a source compatibility problem, we apply the same rule as for do...catch statements to limit inference: throw statements within the closure body are treated as having the type any Error in Swift 5.

    I don’t see a way around it, but those error messages (and fixits?) are going to need some very special attention. That’s a confusing rabbit hole right there.

  • There’s some useful guidance in the proposal on when to use some Error versus any Error. Make sure that too ends up in the language guide.

  • There was extensive discussion during the pitch phase about introducing (untagged) sum types to Swift as a way of letting functions throw multiple types, as “Alternatives considered” briefly mentions. I’m skeptical of this direction, and happy that we are not proposing it at this time. (I’m similarly happy that we are not proposing multiple thrown types, which are just sum types in disguise, for exactly the reasons the proposal mentions.)

    Why don’t we need sum types right away? I’m looking at the proposal’s guidance, which is solid, on when typed throws is appropriate:

    1. Code that stays within a module or package, where the caller always fully handles the error.
    2. In generic code that passes through errors from other components.
    3. Dependency-free code that is meant to be used in a constrained environment.

    Items 1 and 3 both imply a closed world of known possibilities, in which an explicit enum of possible error cases is entirely appropriate. Item 2 is just the transitive closure of cases 1 and 3.

    Given that, I only see two likely reasons why type throws would make sum types compelling:

    • Typescript developers craving Typescript familiarity.
    • Failure to heed the proposal’s recommendations about when typed throws is appropriate, leading to a need for sort-of-source-stable retrofitting of a new error type in an existing API.

    If we use this new feature wisely, I think we’ll find that we rarely want multiple thrown types, and when we do find ourselves wanting that, it’s a sign that an enum is the proper design choice. (Note that Typescript’s design constraint of retrofitting untyped Javascript APIs puts it in a position where tagged unions can’t work. We don’t have that problem in Swift unless we choose to have it!)

    We’ve already put tremendous effort into thinking about the interaction of enum with library evolution. Let’s put that work to use with typed throws, live with the feature for a while, and then reassess whether multiple thrown types or untagged sum types actually solve a real problem.

Again, great work on the proposal.

14 Likes

Yes.

It's a convenience, but one that we've always had when you use generic types within a function signature. As you noted, this is what implies the Error conformance for E in Result<T, E>, and its always what implies a Hashable conformance for K in [K: V].

I think the uniform handling of throwing and non-throwing cases far outweighs the potential for confusion here. That's why we have Never in the type system, and why it conforms to Error in the first place.

I agree that this is the key semantics of rethrows. With typed throws, this semantics mostly falls out from the definition of a generic function where the same type parameter is used for the throwing closure parameters and the resulting function. The map example:

public func map<U, E>( _ transform: (Wrapped) throws(E) -> U ) throws(E) -> U?

In the body of map, how are you going to throw an instance of type E if not by calling transform? Its only constraint is that it conforms to Error, and that protocol doesn't have any way to create a new instance. One would need to either add more constraints on E (which will show up in the signature) or do some kind of dynamic casting to create an instance of E.

I suspect that rethrows will ultimately be removed from the language. rethrows is in this unfortunate place where there are existing soundness holes in the checking; yet these soundness holes are load-bearing because there are patterns you cannot write with rethrows (the proposal shows this), and code in the wild is using these holes to provide good APIs. So rethrows either needs to be extended and made sound (e.g., the pitch I linked that proposes rethrows(unsafe)), or it needs to be removed.

This proposal isn't removing rethrows outright for several reasons:

  1. It's a source-breaking change in a proposal that tries very hard not to be source-breaking.
  2. It's possible that there uses of rethrows not well-covered by typed throws (although I have yet to see one).
  3. The bar for removal of an existing feature is significantly higher than for the introduction of a new feature, and I don't want this proposal to be delayed by that higher bar.

It might be reasonable to immediately follow up this proposal with another one that lays out the argument for eliminating rethrows. Personally, I think we can let it sit for a year or more to see whether typed throws has subsumed all uses of rethrows.

It's trickier than that, because refutable patterns can also have arbitrary Boolean conditions in them. If I have series of catches that each match the HomeworkError enum and check for one of the cases of that enum, and each case is checked, is that also exhaustive?

The actual issue is in closure type inference, though. A syntactically-exhaustive do..catch in a closure means that we know the closure isn't throwing. Doing semantic exhaustiveness checks like you describe means putting the complicated exhaustiveness algorithm (like the one used for switch) in the middle of type inference, meaning it may have to be run many times with different types as input---which can explode type-checking times. We avoid this problem with switch exhaustiveness checking because it only happens later in the compiler... and even then, we had to add timeouts for super-complex switch statements because it's effectively solving the satisfiability problem.

Doug

15 Likes

And this here is a concise summary of why functional programming with parametric polymorphism is good—instead of reasoning about the dynamic execution of your program, you define your types so that the only possible way to inhabit them already satisfies your invariants.

Dynamic casting can fail, but as long as your function is total, you can only throw a thing you caught. This is expressed in the type signature, without the compiler having the reason about execution (which in the case of rethrows we know it does incorrectly).

18 Likes

Would it be substantially more expensive to add the minimum of logic to handle the example given above? That is:

do {
  try callCat() // throws CatError
  try callKids() // throw KidError
} catch is CatError {
    print("Error was CatError")
} catch is KidError {
    print("Error was KidError")
}

I would imagine that from an end-user perspective the lack of heroic efforts to check exhaustivity is totally understandable, but the lack of any attempt to accommodate any more than one type might be rather more surprising.

Of course, I think it's also fair to point out that the end user's code can be trivially rewritten:

// ...
catch is CatError {
    print("Error was CatError")
} catch {
    // assert(error is KidError)
    print("Error was KidError")
}
2 Likes

Indeed, and I was picturing a different trivial rewrite:

do {
    try callCat() // throws CatError
} catch is CatError {
    print("Error was CatError")
}
do {
    try callKids() // throw KidError
} catch is KidError {
    print("Error was KidError")
}

In most situations, I’d think that splitting up the error handling like this is something we’d want to encourage.

Is there a compelling reason for more compiler magic to allow people to avoid that? Scoping inconveniences with do-local variables, maybe, where it becomes necessary to declare a local variable outside the do/catch block and/or nest the do/catch blocks inside a larger do block? There might be something there, though I’m skeptical of whether the problem is common enough or serious enough to warrant language gymnastics.

Edit: I suppose the more serious motivation would be control flow — and my own rewrite above shows that. :person_facepalming: In a situation where you want the earlier error to bypass the later code but still be exhaustively handled, the alternatives to the aggregated catch block is another pyramid of doom:

do {
    try callCat() // throws CatError
    do {
        try callKids() // throw KidError
    } catch is KidError {
        print("Error was KidError")
    }
} catch is CatError {
    print("Error was CatError")
}
2 Likes