Typed throw functions

What I tried to say is that, from this:

- (NSArray<NSString *> *)contentsOfDirectoryAtPath:(NSString *)path 
   error:(NSError * _Nullable *)error;

Where we got the error implicitly in the call so we could know the type (doesn't matter to me now if it was more expressive than Error or not, that's not the point, neither I'm suggesting to go back to ObjC).

To this

func contentsOfDirectory(atPath path: String) throws -> [String]

Where we lost totally the context of what is being thrown. As I agree with you that multipattern catch might help, It does not help in any way if you don't know what to catch in first place, which makes you go to the documentation and find what is being thrown at each moment by each function, whereas

func contentsOfDirectory(atPath path: String) throws NSError -> [String]

Is self-documented (a thing that I always try to achieve when I write down code. And which is not available for me right now in Swift unless I mark in the documentation of the method which Error is throwing.

I expect to have explained myself enough with from the last 2 posts that obviously didn't make my point sufficiently clear.

1 Like

I don't know if it was already mentioned upthread or not, but one of the reasons I want typed throws is exactly this:

This would also beautifully fit with throwing accessors:

4 Likes

This'll be my last post on this particular topic since I don't think it's very relevant to the discussion at hand.

It may sound good in theory, but the example is not exactly convincing. I checked the documentation and still can't say which of the error I'm fishing for. NSError is ubiquitous. It might as well throw Error and that wouldn't make any difference. Cases like this are precisely appropriate for untyped errors.

I'll give the idea the benefit of the doubt, but Foundation is not a good example given that it only uses NSError and most functions don't even bother documenting which error it is throwing.


I think a stronger point regarding its usage (so excluding things like type system unification, etc.) lies in where typed and untyped errors differ. Typed errors do enjoy exhaustivity check, which would fill in the gap between Optional (exactly one error) vs throws (unlimited error). It's probably important anyway to arrive at a guideline as to when the typed error should be used instead of something else.

I really appreciate all of your contributions to the topic and appreciate the caveats and strong points. Let more people participate in the topic (if they want) and then we can make decisions. I'm here just to move the topic but not to make it mandatory :smile:
Being true that NSError was the Error that we have in Swift where anything can be behind the scenes. I feel that adding the type breaks that uncertainty against catching Error but catching ConcreteError and make a better error handling. This is unlikely to be implemented in Foundation (farther than generating an automatic conversion from ObjC NSError functions to throws NSError) so the "old" Foundation probably will be there as a monolith.
New implementations in Swift are another topic, because they now start to throw Error descendants, which makes this type of annotation more useful.

I agree, I have someone in my team that is making exactly the same argument - they eschew throws entirely in favor of Result because you can type the error correctly.

Type-erased errors are very important for large scale app frameworks, but that is not the only kind of code people build in Swift. Pushing people to use Result instead of throws is a failure of the design, not something to be celebrated. The throws design in Swift is carefully considered and a huge progression vs manual error propagation, we shouldn't hobble it like this.

I (still) think that lack of typed errors is a huge omission from Swift's error handling approach.

-Chris

26 Likes

In my team we're pushed towards a Result API because we don't have any other tool to type our errors thrown from different architectural layers. For me, opinion, Result was added as a lack on the error handling system which the multi catch pattern nothing has to do with. I end up always using get() to have a throwing method and avoid map closure hells.

That's why I'm convinced that there's something in the language that feels not finished.

2 Likes

Sure - so my issue with typed throws isn't so much with the specific feature itself. Error is just a marker protocol, and by itself is no more useful than Any. You wouldn't write an API taking Any parameters and returning Any results, so it makes writing code (especially code which actually handles errors) more awkward to write as you have to claw back that type information via dynamic casts and handle failure cases which you know will never happen (especially in a single module).

That said, I think that introducing typed throws right now would lead to an overall worse error model than we currently have. There are a couple of reasons for this, mostly revolving around enums. Conceptually, enums are a really great fit for modelling errors, so it isn't surprising that those flaws really get exposed through throwing functions. Also, I agree with @Lantua that the major motivating use-case for typed throws is exhaustive catching, and that only works on enums anyway. So enums and typed throws have a very strong link.

The biggest issue is that you cannot add new cases to public enums. This is something of a flaw in the language - and has been a topic of much discussion. Essentially, it means that we shouldn't really be allowing exhaustive switch or catch on enums from other modules in the general case anyway - you should always have to handle @unknown default cases, so the enum has freedom to grow. We currently have a quirk in the language so if the enum's declaring module was compiled with library-evolution, it will actually be extensible and cannot be exhaustively switched/caught unless you opt-in to that. The thing is - it's also a massive issue for source stability, not just binary compatibility. Unfortunately, there isn't even a way for enums in regular modules to opt-in to that very important flexibility (that's what the proposal I linked to aims to address).

So why is this a problem? Well, let's take your example:

public enum AuthenticationError: Error {
    case invalidCredentials
    case unknownReason
}

public func authenticate(with username: String, and password: String) throws AuthenticationError

Let's imagine this is part of a library - not even an ABI-stable library, it can even be shipped as source. Now, you've had the foresight to add an unknownReason catch-all clause, which is good. Lots of developers won't think of that - especially if they've never run in to the issue themselves or frequent these forums to have heard about it. But it won't save you ;)

So they ship the above code in v1.0 - but soon after, they notice some suspect behaviour, and decide to rate-limit the authentication requests. Now, this company has really poor frontend caches, so they recommend (but don't require) that Apps handle this specific error and enforce the limit on the client side. Even with an unknownReason case though, we still don't have any ability to add errors to the function, other than bumping it up to v2.0. This creates yet further headaches with package dependency resolution, as all dependencies must be compatible with the same major version of the library.

So you can see how enums make this feature impossible to use without limiting how your function's implementation can evolve, even if it isn't @inlinable. That's why I think it's important to fix enums before we give them even more significance by exposing them in this way.


So that's an overview of the problem. Will it be an actual issue for developers in the real world? I think it will. Just looking at this thread:

It sounds very much like people think that typed throws is something that they "should" do. Of course, it would eliminate some boilerplate, but in many cases, it could inadvertently expose some non-obvious pitfalls in the language's library evolution story (both for ABI and source stability).

In general, I agree most with the sentiments expressed by @John_McCall:

P.S. For @Chris_Lattner3: Are those functions public and are the errors enum types? If so, is this person aware of the evolution constraints?

Let me be clear, I think the errors that would benefit from exhaustive check (and hence typed error) would:

  • Be exhaustible (mostly enum),
    • Don’t have a case that simply wraps an inexhaustible error,
  • Don’t have unknownError and the likes–it’d lean more toward untyped throws,
  • Expect each error case to be handled differently–it could use Optional otherwise.

Personally, I haven’t seen many cases (if at all) that would fit this criteria.

2 Likes

I think it is developer responsibility to bump to v2.0 or not if an Error case is added. Many of them won't, because it is an additive change and not a breaking change hence the bump would be minor in most cases. If you add a case you have the exhaustive switch, which make the change additive and just have to update a case. At any moment the developer made the enum @frozen so it is able to add as many cases as he wants. If you're making just a generic catch, you won't care about new additions, you're just behaving as you'd do now: "I know there was an error, let's log/print/prompt to the user the localizedDescription", for example.
I don't see the issue you describe as an issue or as something the community cannot learn from, as it does from every additive change we make to Swift (and we don't make major versions for a while :stuck_out_tongue:)

1 Like

Though they are strongly linked, exhaustive catching isn't the only use-case for typed errors. Boilerplate elimination is a reasonable ask, too. In order to decouple those things, we need to fix enum evolution.

The enum improvements should be high-priority fixes anyway IMHO, regardless of our plans for typed errors.

Which boilerplate are you thinking about?

Generally removing downcasts and the corresponding failure branches, by virtue of knowing the exact type of error. That's plain type-safety, and there are optimisation opportunities from knowing that. Whether or not you can exhaustively handle every value of that type is (or should be) a separate question.

Removing boilerplate is not going to be the #1 most amazing feature, guaranteed to knock your socks off, but it's a reasonable ask.

I see. You mean something like this?

// bad 1
do { try untypedThrow() }
catch let error as TestError {
  // error is `TestError`
} catch { /* dead code */ }

// bad 2
do { try untypedThrow() }
catch {
  let error = error as! TestError
  // error is `TestError`
}

// good
do { try typedThrow() }
catch {
  // error is `TestError`
}
3 Likes

Quoting myself in the past:

At this point, is anyone interested in helping me writing a pitch and in parallel in some implementation work (basic semantics to start of, AST additions, etc.).

Thank you!

Keep in mind that people can already do the "bad thing" that you're worried about here: they can vend a public API implemented with Result and a typed error. Adding this to throws doesn't introduce a new evolution problem, it makes the language more consistent.

Beyond that, there is a lot of code in the world that doesn't care about long term evolution, because it isn't a public API. A very good (and pretty standard) design is to maintain a minimal public API (where API evolution is critical) but then have a large / complex internal implementation details within the module.

Also, a lot of people build apps and other leaf things that are not meant to be shared as API. Evolution considerations are quite meaningless here. There are also projects with different philosophies w.r.t. their APIs - e.g. LLVM and Swift intentionally don't maintain stable C++ APIs, despite having downstream users.

My point here is that "long term evolution" is important for large scale app frameworks and other important classes of libraries, but this is only one of the sorts of thing that people build with Swift. We are underserving the other cases, pushing them to use Result with typed errors.

-Chris

16 Likes

No arguments there - they certainly can do that. I'm considering this from the perspective of "what if there was a PR for typed throws tomorrow?", and whether we want to encourage developers to commit to particular Error types now, while enums are still so broken. Again, this is all about enums - but they are closely related to this feature (especially when it comes to exhaustive catching), so it's important to consider them both.

No argument here, either - however, my understanding is that one of Swift's goals is indeed to support both forwards and backwards compatibility for libraries, and to make it explicit whenever you give up future flexibility for performance or other reasons.

Of course I'm not objecting to propagating error type information whatsoever (and as you say, you can do with Result). That's a decision that developers can make on a case-by-case basis: the problem is that as things stand, enums violate the contract mentioned above. I think I am correct in saying that they are the only types in the language for whom adding information (i.e. data members/cases) is a source-breaking change. It's an ordering issue: we should fix that language inconsistency before we think about tightening contracts for error types, because the 2 features are so closely related. Doing it the other way around is worse.

Yes, I agree with you that typed throws is a feature that could be used incorrectly w.r.t. API design (this is typical of almost all features in Swift), and I agree with you that improvements to enum would also be important.

Neither concern seems like it should slow down progress on typed throws though.

1 Like

An authentication can fail for two basic reasons: either you have the wrong credentials, or there was a mechanical failure in the attempt, like a network failure. These two things usually need to be handled/reported differently. There is no reason to make them the same type of error except a misguided belief that having a single error type is somehow better.

This is exactly the sort of thing that makes me not want to support typed throws: a well-meaning programmer corners themselves into a bad design because they instinctually believe that the abstract taxonomy they're developing is useful.

9 Likes

And that does not apply to Result?

Terms of Service

Privacy Policy

Cookie Policy