Typed throws

Just to make sure we are all on the same page, because it does not sound to me like we do. ^^

3 Likes

Developers Needed:
In order to get the proposal to a review stage, is needed an implementation. I'm not a compiler expert but I have plenty of experience on C++ and Swift itself.

If there's people eager to help with the implementation please, DM me asap: [AST][Sema] Typed throws implementation by minuscorp · Pull Request #33653 · apple/swift · GitHub

Let make that clear. rethrows means "if at least one parameter throws, the function also throws". It does say nothing about the error type and the current behaviour always assumes Error to be rethrown. So as it is said in the linked document, can we agree on these general "new" rules?

  • rethrows T : Throw T if parameter throws
  • rethrows === (rethrows T where T === Error) : Throw Error if parameter throws

Yes and I mentioned that upthread already. However I personally do not like the name rethrows with the addition of typed throws anymore. It simply doesn‘t make any sense to call it rethrows T where T is not an error type from any of the parameter closures. However this is a different topic and I shared my ideas above.

Previously, everything that was thrown was hidden behind Error, and everything that was rethrown was also of Error type, where you get the illusion that it rethrows the closure error even it could throw a custom error under the hood. Now with typed throws, the name does not make sense anymore.

I'm sure I'm not the only one who learned in this thread, with great astonishment, that rethrows functions could throw some other error than the ones thrown by the closure arguments.

I wonder how many people knew about that and did put it to use.

5 Likes

I think I have written code that catches a thrown error and then rethrows a different error. I don’t recall specific details at the moment though.

1 Like

It makes sense to me though. If you can throw only in response to an error thrown from the closure, I think it's fair to say you're rethrowing the error. You can change the representation of the error while in route, but it's the same error underneath.

You can also swallow the error instead of rethrowing it. This is another kind of tinkering.

Maybe we want a "passthrough" modifier that'll guaranty errors are passing through unmodified. It doesn't seem to me having this narrower kind of rethrows would enable anything useful though.

I was thinking about this some more, and I'm concerned about the implicit Error conformance inference problem. More generally, I don't think it is a good idea to infer protocol requirements and not have them written down in the source. Consider something with the signature:

func f<E1>() throws S<E1> {}
func g<E1, E2>() throws E2 where E1 == E2 {}

Does E1 implicitly get an Error requirement? What if S<E1> conforms to Error regardless of what E1 is? What if S<E1> conforms to Error only when E1 conforms to Error? This gets even hairier when you consider protocols + associated types. People shouldn't have to play type-checker in their head to figure out what conformances are expected but not written down in the source.

We already have some inference in the form of associated type inference, and that is a constant source of bugs. It would not be great if we added a new form of inference and that is another source of repeated issues...

Is there any reason why this is meaningfully different from:

struct S<T> {}
extension S: Hashable, Equatable where T: Hashable {}

func f<T>(_: T.Type) -> [S<T>: Int] { return [:] }

struct R {}
let _ = f(R.self) // Error: 'f' requires that 'R' conform to 'Hashable'

?

I take your point that these implicit constraints might be confusing (and I was against the implicit Error constraint initially), but the fact is that we allow implicit constraints based on the function signature already, and I think it would be a bit inconsistent to not infer the Error constraint in the situations you've raised.

2 Likes

It’s also not specific to dictionaries:

struct Foo<T: Hashable> {}
func foo<T>(arg: T) -> Foo<T> { fatalError() }
struct NotHashable {}
foo(arg: NotHashable()) // error

Looks like we infer the constraint implicitly in scenarios like above if it’s not there explicitly...

2 Likes

Yikes, I hadn't realized that. I don't think we should allow that either, but here we are. :man_shrugging:

1 Like

There's a difference though between your example and @Jumhyn's (and I think @Jumhyn's is more disturbing). The constraint in your case is on the type, so it is somewhat understandable why that's not duplicated. However, in the extension case, that seems more sinister because the extension could be anywhere in the module (and there may be many extensions scattered throughout the module), whereas the type definition is in one place.

Yeah, that’s a good point. To be honest, I wasn’t aware of this kind of inference so this is a TIL moment for me!

We infer the constraints that are necessary to make a signature well-formed. The signature of f in Jumhyn's example requires that S<T> conform to Hashable because of the constraints of Dictionary. The only subtle thing is that that gets applied recursively to T because of S's conditional conformance.

Inferring an Error constraint on thrown types is absolutely the right thing to do here.

6 Likes

First of all, thanks for circling back and tackling this issue. I think it's a worthy idea to revisit. I've been following the discussion that's unfurled and would chime in with a few thoughts:

You have a choice here to frame your goals in one of two ways: introduce typed throws to the language, or introduce typed error propagation. The title of your proposal suggests the first, while the discussion suggests the second.

No doubt, introducing typed error propagation is more interesting, but it is also significantly harder. You'd be designing and implementing a parallel error handling system and plumbing it through the whole language. If Swift were a blank slate, I would imagine that it would probably involve as much effort as designing and implementing async/await. Since we have ABI and source compatibility requirements, I would estimate that the task is actually harder than implementing async/await.


Suppose your aim is to introduce only typed throws:

This would serve to move what can be documented currently in a structured doc comment into the type signature. Currently, Swift understands the relationship between non-throwing functions and (untyped) throwing functions. You would need to design additionally what subtyping relationships are desired between non-throwing functions and typed throwing functions, and between untyped throws and typed throws. These relationships are important because it would allow users to use functions with typed throws seamlessly within the existing design of untyped error propagation.

What can the compiler do with typed throws alone? Well, you are right in suggesting that it would be limited, but it's not nothing. If a user catches a single error and there is a known type MyError1, then the compiler can tell the user that catch let error as MyError2 will never be executed. Likewise, it would allow users to use "leading-dot" syntax for type inference when implementing a function with typed throws.

It is reasonable to scope your proposal to this alone and judge whether the additional syntactic complexity is worth the benefits here.

I would suggest strongly that you omit any arguments about "symmetry" (particularly since the argument is for symmetry with Result, which was explicitly approved with the understanding that "async/await is likely to greatly diminish the importance of this type") and focus on the practical use cases that would be enabled by such a design.

Chris Lattner has pointed out that there are certain domains of programming where this is most useful, while others have pointed out that there are certain other domains where this is not advisable. I would suggest exploring this further and showing where current uses of untyped throws in certain programming domains falls short and cannot be remedied using Result. Swift, as an opinionated language, tries to steer users towards best practices, and it is useful to define what we think those are when we add new features to the language. See, for example, the core team's approach to Result:

The Core Team acknowledges the call from several reviewers to make a special effort to communicate how we think Result ought to be used in Swift. We absolutely agree that is an important part of adding Result to the language, and we intend to provide this guidance as part of the release.

One benefit of scoping your proposal to typed throws only is that, since we are using the existing untyped error propagation, a library author who chooses to annotate the type of their throwing functions when it wouldn't be recommended would have no or minimal negative impact on downstream users, since everything else still works the same. If typed throws turn out to be not a good design decision, then it will simply not be used.


Suppose your aim is to introduce typed error propagation:

I would strongly suggest that you review the existing error handling documentation here and here. Since you would be proposing a completely parallel system to the existing untyped error propagation, it would behoove the project to have a similarly complete rationale; some questions to consider:

  • What is the state of the art since Swift 2.0 for typed error propagation in other languages? Which languages have adopted it and which have moved away from it? What are the strengths and weaknesses of these designs?

  • What use cases are easily performed in those languages that use typed error propagation that aren't possible or ergonomic in Swift? What use cases are made less ergonomic? How can we create a design that maximizes the former and minimizes the latter?

We really need to consider these topics in full before delving into the design of specific keywords, since without a larger vision we would just be guessing about how each piece fits together. I think on the whole that such an undertaking would merit a new manifesto; it is unlikely to fit into a single proposal, and I don't think creating the whole system one piece at a time will yield optimal results.

But now let us assume that we can create a very compelling rationale for this (large, nontrivial) addition to Swift. What would be the requirements for any such design? I can think of a few:

First, it is a given that we will continue to have and support first-class untyped error propagation. Users have expressed that this is the appropriate approach for their domain of programming, not to mention that changing a tentpole feature of Swift is not permitted at this point in its evolution. What does first-class support mean? A user should be able to use all existing and future facilities without dealing with typed errors. To wit:

  • Untyped throws, rethrows, and catch must continue to exist and work as they currently do.

  • If a user encounters an API with a typed error, they must be able to propagate that error via the existing untyped error propagation facilities, and to do so ergonomically (it should not require writing additional overloads or wrapper functions).

Second, to have typed error propagation means that the type of the error needs to propagate. There should be first-class support also for working in this paradigm. That means that in the ordinary course of bubbling the error up via rethrowing, or catching the error to make use of it in some way, an error of type MyError1 shouldn't turn into an error of type Error (unless the user opts into untyped error propagation voluntarily). So:

  • There must be a way to rethrow an error without upcasting, boxing, or erasing the type.

  • There must be a way to catch an error without upcasting, boxing, or erasing the type.

  • Neither of these may interfere with first-class untyped error propagation.

  • There must be a way to use typed error propagation with existing APIs in the standard library designed for untyped error propagation, and it must be possible for a library author going forward to write a single function that can be used with either paradigm. We can neither change the signature of the existing APIs in the standard library, nor will it do to just overload everything. It follows that there must be added some way to annotate existing APIs so that they work with typed error propagation which doesn't cause ABI incompatibility.

  • There should be a way to call two or more throwing functions without the errors being upcast, boxed, or erased; we need to do this even as we accept that A | B will not be added to the language. Perhaps a first-class Either type would suffice here.

This is a very long list of requirements, to be sure, but I think the inherent logic of the problem dictates these requirements. If a typed error cannot actually be propagated--that is, if users will have to deal with errors of type Error a significant proportion of the time despite implementing a subset of the requirements above--or if implementing a complete design for typed error propagation isn't possible in Swift because it cannot coexist with untyped error propagation, then the natural "resting point" for the language is either the status quo or the addition of typed throws only, without attempting to make any modification to the error propagation part.


TL;DR:

  • There is a distinction between introducing typed throws and introducing typed error propagation; the former is a much more tractable problem to tackle in a single proposal.

  • The compiler can do some useful things for the user with typed throws alone; therefore, typed throws can be evaluated on its own merits even if typed error propagation can't be introduced now or later.

  • Adding typed error propagation is not trivial but a major expansion of Swift on the level of adding async/await; we need to look at the big picture (like the Error Handling Rationale did for Swift 2.0) rather than designing it piecemeal one keyword at a time.

  • Even as there are use cases when type errors have benefits, we would want to reap those benefits without duplicating the weaknesses of other languages' designs for typed error propagation. (This implies that we study carefully what those weaknesses are.)

  • There are some requirements for the design for typed error propagation that are inherent to the goal of propagating the error's type. If not all requirements can be met, then by construction users would encounter difficulty actually propagating errors without losing their concrete types somewhere along the way. Beyond typed throws, it would best not to make half-way changes to the language if we cannot envision a path forward to actually achieving typed error propagation.

31 Likes

You write absolutely beautifully! I always appreciate reading your posts. Thank you

Thanks a lot! What an in-depth list of arguments to consider. I guess this is the most complete homework I've ever got. :sweat_smile:

Besides all the other points this is one of the hardest points for typed error propagation as far as I can see. I totally agree with you that we need something like sum types for a convenient propagation solution. I don't think that an Either type will be sufficient (e.g. no merging of cases is supported). So as long as the rejection of sum types is set (and from what I know it is), I can see no way forward to come up with a convenient solution for Swift. It's just by nature of errors and program structures that sum types would be the matching answer here. If there would be room to revise the rejection of sum types, I would be into exploring the field of error propagation further, because from my point of view it's the field where most programming languages fall short.

From what you write I don't see where we are propagating typed error propagation then. :thinking: For me the difference would be that you can infer the resulting sum error type from the sub programs. Do you have another key difference I did not get from your post? Because as long as we are doing error conversion manually we propose what you describe with "typed throws"?

The main argument comparing untyped throws with Result is the lack of error information that untyped throws has compared to Result. There is a reason I Result.flatMap all the way to some of my programs. And we laid that out in the proposal.

We also accept that Result and throws will not be used interchangeable in the future.

But from what we know, this was already discussed before and was rejected in favour of a performant throws implementation.

We just wanted to express that we think it would be the most elegant solution. But maybe we can remove this fantasy from the proposal. An async/await will not diminish the importance of Result for typed error propagation. Async/await and untyped throws will be a potential replacement for Future<T, Error> in some cases. I can't see where Async/await can help with typed error propagation at all if we are not introducing a typed throws. Looking at semantics async/await and throws/try are just syntactic sugar for types that could represent the effects described by them. They could also be represented by types like Result<T, E> or some fantasy Task<T>. But in Swift it's not only syntactic sugar, it's some separate language mechanism, which makes it harder to compose it with other types. But there are performance reasons I guess, why it was decided this way and I don't want to question that.

Yes we could do that, though I'm not sure if it's connected to domains or if it's more connected to developers preference and the compiler backing / safety a language user is aiming for. I've seen devs being comfortable with throwing things around and being just fine. Until I ask them questions in reviews about why they think this error can be catched at this point. And they often answer with "I don't care thaaat much about it". So to me it often seems to be connected to personal preference.

Thanks again for summarizing all these points we need to take care of! For me the hardest point is to get a definite answer to some of these points from decision makers of the community.

If we know that sum types are a NO once and for all, we can concentrate on typed throws (manual error converting).

1 Like

Union types have been rejected. I think there has been skepticism expressed about anonymous sum types but I don't believe they have been outright rejected.

1 Like

Ok to be precise when I speak about sum types I mean this Tagged union - Wikipedia. A tagged union where "tags" are generated by the compiler so we can pattern match like in this proposal: https://github.com/frogcjn/swift-evolution/blob/master/proposals/xxxx-union-type.md.

From what I know this is rejected.

I do not quite understand the difference between typed throws and typed error propagation.

Does the former mean, that the errors are typed only at compile time, but passed as Swift.Error at runtime, while in the latter case we would basically return the actual type when throwing at runtime?
In this case there would only be a performance difference, wouldn't it? So I'm probably misunderstanding something.

I have read both documents that you linked to, but that didn't enlighten me, as the author used the term typed with a completely different meaning.

1 Like