Typed throw functions

You either catch both errors in their corresponding catch clause and throw the convenient throwing type of the function or maybe typed function is not appropriate for that function.
This is not for every function. It has its use cases

1 Like

IIRC that is mentioned near Library Evolution section

I don't have time to read this whole thread or read the draft in detail, but it looks like you haven't adopted the design where non-throwing functions are equivalent to throws Never. I strongly suggest you consider going in this direction as it improves composability and addresses the complications around rethrows. Here's an old thread that is contains some relevant discussion.

6 Likes

We never rejected that idea, but it seems more than an implementation detail to me. Also none of the authors encountered issues with rethrows for now, but thank you for the reference, I'll check it out.

Edit: I don't think we want to erase rethrows from the language rn, as it does not impact on the proposal, also, also I don't think we want to make everything throw typed as it does not solve some issues that we have, they add even more edge cases.

This isn't an implementation issue, it's much more than that. It's central to how typed throws fits into he type system.

I'm not suggesting you do that.

You don't need to do that at the syntax level, but in the analysis I've done it simplifies things at the semantic level to interpret the existing syntax in terms of throws Never and throws Error.

7 Likes

Then apologies for not understanding your point in first place. I'll take in consideration how does it fit in the type system, I trust your analysis, we'll add it to the proposal in the grammar section :smile:

1 Like

You're right. It is a non-goal (in my opinion) for Swift's error handling to be as expressive as TypeScript or Java's error handling. Its only goal is to be great for Swift.

On a few other points, yes, closures (and function types in general) should support typed throws. rethrows should not take a type, it should propagate the type from the closure to the callee. This would make sure that:

   try { 
     ... = try thing.map { foo($0) }
   } catch let x {
   }

infers x to FooError if foo is a function that throws only FooError.

-Chris

5 Likes

I agree w/@Chris_Lattner3 that rethrow should just match argument. It typed throws only if the argument is typed throw. The usage of rethrow is only to propagate the throwing anyway.

Now there's some interesting capabilities when we include typed throws, we may be able to restrict the error type:

func foo<T: FooError>(_: () throws FooError -> ()) {...}

But it seems to be a very questionable feature.

Right now, a function can only specify one thrown type, So what does a function that takes multiple closure arguments which may have distinct thrown types do during rethrows? It:

  1. Throws dual types, although the user can't directly spell out such types?
  2. Throws the most-derived common base type (which may be Error)?
  3. Uses the general throw?
  4. Does [1], but we add support user-level spelling of multiple thrown types?

I'm aiming towards [4].

2 Likes

This is what I would expect.

#1 is not possible - you can only specify one error type. I don't understand what you mean with #4.

Related to rethrows, here's an example to consider:

func takesTwo<E, F>(_ e: () throws E -> Void, _ f: () throws F -> Void) throws E -> Void {
  try e()
  do {
    try f()
  } catch _ {
    print("I'm swallowing f's error")
  }
}

This works like rethrows in that when E is Never the call to takesTwo will not be a throwing call. It works better than rethrows in that it is not throwing even when F is Error or some other error type (i.e. not Never). This is an example of why it is important to make non-throwing functions equivalent to throws Never.

He's trying to support multiple error type throwing (not that I support this idea):

func foo() throws FooError, BarError { ... }

I think it should be similar to array literal with different types: if the type doesn't exactly match, the rethrows becomes untyped throws. I don't think adding more expressivity to rethrow gains us anything. It's suppose to be a mechanism to simply propagate the errors from the arguments.

It should throw the common supertype, which will often be Error.

1 Like

Making this choice in language evolution, without also adding the type-level ability to compute the common supertype in user code, would be limiting in my opinion. (Think switching between typed throws and composing Results; now the latter would be less expressive.) We don't have a type construct like CommonSupertypeOf<MyError, YourError> in Swift yet, do we?

Maybe we should consider just falling back to Error in this case. Otherwise it seems we'd need more advances to the type system than the core team is willing to.

1 Like

I noted your post down and read it now. As far as I understand, your issue is:

// Protocol provided by a third party for making data loading configurable (via e.g. dependency injection)
protocol DataLoader {
    func load() throws -> Data // which type `throws` should have?
}

// Your loading solution
class HTTPDataLoader: DataLoader {
    func load() throws -> Data
}

I don't get why some third party wants to force you to use a typed error here? It makes the system less configurable.

For representing error chains, we could think about a guideline. For myself I use this one: https://developer.apple.com/documentation/swift/decodingerror/context/2921331-underlyingerror

Because then I have no discussions, if it's swifty enough.

I guess we should rethrow Error. But I would hope for an API developer to provide an union type matching the number of closures, even if that could mean having cases with the same error type. So it would be nice to have these union types in the standard library, but I have the feeling, it won't happen.

enum ErrorUnion2<E1: Error, E2: Error>: Error {
    case first(E1)
    case second(E2)
}

func takesTwo<E, F>(_ e: () throws E -> Void, _ f: () throws F -> Void) throws ErrorUnion2<E, F> -> Void

But loosing the error information because I called a helper function like takesTwo somewhere in the call stack, would destroy all the effort put into explicit errors in the first place.

Ok good that we cleared that up. :+1: For me it would be nice to write code like it's proposed here: https://github.com/frogcjn/swift-evolution/blob/master/proposals/xxxx-union-type.md. But I totally get it, that it would be too much effort to bring this to life.

So I conclude:

  • There won't be sum types in Swift, where the tag of a union case is generated by the compiler (like in the linked proposal, or some kind of "auto generated enums")
  • So every error conversion needed to merge or narrow errors must be done explicitly by the developer (catch/throw/handle on every conversion or Result.mapError)

Typically it is because they have fallen for the allure of typed errors. Maybe they have created a game engine, and decided to return GameEngineError everywhere. But they then have to limit any code you write to extend the game engine to only throw specific types of errors, because they need to map everything into a more limited concept of errors that their logic can understand. However, since you often can't response to errors you don't understand, this winds up just being dropping the actual cause of the error, or wrapping it in a generic error escape hatch. like case other(Error)

The errors of tightly integrated components make sense as a contract for communicating information between tightly integrated components, even potentially at the cost of requiring refactoring to deal with error cases discovered later. This cost might even be considered an investment in making such components more robust.

At the same time, most applications have legitimate exceptional behavior, such as an app trying to read a file only for it to exist in a bad sector of the drive. These are not problems you were prepared for, so all you can do is attempt to fail gracefully. Often the most you can do is try to present the error to the user, but specifics in the content of the error are only meaningful for a typical user of the system in the context of calling for technical support (and, if logged, to enable someone maintaining or supporting the system to diagnose and propose solutions to the problem). If the operator of the system knows more about dealing with exceptional behavior than the creators and maintainers of the system, there was a problem somewhere in the process.

In Swift, internal errors with semantics and unexpected exceptions are represented using the exact same mechanisms. When you try to limit use to a typed Error to define the exact behaviors that a caller should be prepared for, you are also changing how that code would report unexpected and unrecoverable errors. In a world of reusable code, it is very possible that the logic to handle an unexpected error exists in a caller.

Perhaps a framework has its typed error enum have an other(Error) case, but that sort of contract actually affects any code which might have tried to give the user helpful information in the cases of a data format error vs an I/O error vs an access error - in some contexts catching an Error might give you some specific IO or data format type, and in others it gives say FrameworkError.other which you must disassemble.

The meaning of a particular error only exists as part of the context of being called, and applications are nothing but complex compositions of contexts. What looks like a sum type problem at first is a consequence of pretending this composition can make sense, that it is more appropriate for a method to try to apply new meaning to errors it does not and can not understand than to have it pass them on unmodified. Error handling is often a cross-cutting concern; the calling method may be what actually understands what the error means, and attempting to apply semantics at a lower level via a mandatory subtype of Error is only interfering.

There are definitely the rare systems programming examples where people are willing to perform the diligence to attempt such error handling, but there are IMHO far more cases where declaring you only throw a specific subtype of Error would be extremely detrimental to the system vs say linter/compiler support around documented error types. This goes even farther when you consider that an error type/case may not be sufficient to fully understand what it means in the context of a particular method, and different methods often only can result in a subset of the possible values of an error subtype.

3 Likes

Thanks for your detailed substantial answer. :slight_smile:

Just sounds like bad API design. If you can wrap the error in a GameEngineError on the extension side, they just could have done it for you in the library, allowing you to throw whatever you want.

Good words for the main use case I see for typed throws. :+1:

For me if it's really unrecoverable, it should have crashed already. Unrecoverable and not executable (at the moment) are different things. And as an API user I want to make the decision what should be retried, reported or just ignored. All these API makers that think, they know what my program wants to do are guessing wrong now and then. They should just provide the information they have (as long as its manageable) and let me do my job of building an application. :sweat_smile:

I tried to understand that, but I guess without an example I am lost in abstract speech. :thinking:

For me that sounds like not being specific enough, which happens fast by using an enum that groups all kinds of errors together. That's why I start with error structs and combine them with enums as needed. Of course without sum types this is a manual enum hassle. I just can say I would prefer to use an API with specific errors over an API that is loosely typed regarding errors, because then a machine manages complexity for me, which from my point of view is the only way to manage complexity in the long run. But I'm interested how that would look like with a linter. I assume it is just another machine based tool, that you need to provide with the same information you would feed into the compiler. You can't circumvent information that must be provided to solve questions.

I strongly second what @anandabits said about throws Never. In general in Swift every function or any accessor does return something. In many cases this is done implicitly for you, as many things return Void even the compiler reserved = operator, but they still return. In that sense every function and every accessor should throw an error, but we can again reduce this so a simple model that provides the implicit functionality we need.

Right now we would like to extend the functionality to functions and closures, but later on we also want accessors to throw.

The basic idea is very simple and easy to teach:

(X, Y, Z) -> W          === (X, Y, Z) throws Never -> W

(X, Y, Z) throws -> W   === (X, Y, Z) throws Error -> W

// T: Swift.Error omitted 
(X, Y, Z) throws T -> W === (X, Y, Z) throws T -> W
3 Likes

Ok can someone explain to a newbie into language design why we need this? I mean to me it's clear that:

(X, Y, Z) -> W === (X, Y, Z) throws Never -> W

It's lifting/wrapping W into Result. So as a second though === is not equivalence I guess? What does this operator mean then?

But for which step do we need this for?