[Pitch N+1] Typed Throws

This isn't Never satisfying arbitrary requirements as a type argument, this is a different behavior related to Never. The compiler does not require you to have a valid return value from a function if all paths call into a Never-returning function. It's no different than the following:

func g() -> Int {
  fatalError()
  // ok, we'll 'Never' return from this function so don't need a return value
}

Trying to satisfy the requirement explicitly fails (similarly to your example where you've provided the Never type annotation):

protocol Foo {}

func f<T: Foo>(_: T.Type) {}

f(Never.self) // error

ETA:

I'm not able to reproduce this behavior:

When I copy/paste your code I see T inferred as MyType, and no warning related to the inferred type.

4 Likes

Usecases of rethrows we discussed so far:
Pass through function

func f(g: () throws -> Void) rethrows {
  try g()
}

Could be expressed as:

func f<E: Error>(g: () throws(E) -> Void) throws(E) {
  try g()
}

This form preserves the error type, but doesn't express whether or not the same error instance will be thrown. But since rethrows doesn't guarantee that either, it can be considered a good enough alternative.

rethrows a wrapped error

struct WrappedError<E: Error>: Error {
  let underlyingError: E
}
func f(g: () throws -> Void) rethrows {
  do {
    try g()
  } catch {
    throw WrappedError(underlyingError: error)
  }
}

Could be expressed as:

func f<E: Error>(g: () throws(E) -> Void) throws(WrappedError<E>) {
  do {
    try g()
  } catch {
    throw WrappedError(underlyingError: error)
  }
}

Throws an independent error, but only when a closure throws

struct IndependentError: Error, CustomStringConvertible {
  let description: String
}
func f(g: () throws -> Void) rethrows {
  do {
    try g()
  } catch {
    throw IndependentError(description: String(describing: error))
  }
}

Could be expressed as:

func f(g: () throws -> Void) throws(IndependentError) {
  do {
    try g()
  } catch {
    throw IndependentError(description: String(describing: error))
  }
}
func f(g: () -> Void) {
  g()
}

Note: make sure it doesn't lead to ambiguity

rethrows function over a typed throws closure

func f(g: () throws(SomeError) -> Void) rethrows { ... }

This should be explicitly prohibited. There are not many directions to make it work, and they all are bad:

  • making it typed will require union types, because otherwise it will break in case of two closures.
  • erasing to any Error doesn't follow the principle of least astonishment, and isn't embedded-friendly.

IMO, it'd be better to explicitly prohibit combination of rethrows and throws(E) where E is anything but any Error.

rethrows function as a closure with an inferred type

func f(g: () throws -> Void) rethrows {
  try g()
}
let f1 = f // (() throws -> ()) throws -> ()

This one is hard. If a function already has a generic parameter - a simple transformation to typed throws can be made. But if it hasn't - then an erased variant should be provided (possibly syntesized).

func f<E: Error>(g: () throws(E) -> Void) throws(E) {
  try g()
}
func f(g: () throws -> Void) rethrows {
  try g()
}
let f1 = f // inferred (() throws -> ()) throws -> ()
let f2 = f as ((() throws(SomeError) -> Void) throws(SomeError) -> Void)

rethrows function throws its own errors

struct MyError: Error {}

func f(_ a: () throws -> Void) rethrows {
  func g(_ b: () throws -> Void) throws {
    throw MyError()
  }
  try g(a)
}

This one is an incorrect usage of rethrows. A version with typed throws wouldn't allow this.

1 Like

I don't believe these two are equivalent, because f unconditionally throws even if g does not. However you can express a relationship between errors with an associated type and achieve something similar perhaps.

1 Like

The second f accepts g: () -> Void which is actually () throws(Never) -> Void, and as Never is a concrete type it will be prioritized over the first f. Am I wrong?

This one relies on a generalization of the rules that is (I believe) straightforward but hasn't been discussed so far. We want to say that WrappedError<Never> behaves like Never for typed throws purposes, so a function throwing WrappedError<Never> is not considered as throwing. Otherwise, this example does not mimic rethrows at all.

We could achieve that if we allowed any uninhabited type to behave like Never in error position. In this case, WrappedError<Never> is uninhabited because it has a stored property of type E.

(The compiler today already has a variety of behaviors that are hardcoded on Never itself, and some on any uninhabited type. I believe most of the control-flow stuff is in the former category, while the type checker's implicit coercion of () -> Never to () -> T checks for the Never nominal type.)

2 Likes

I missed the fact that you had the other overload of f. Yes, if you overload f with throwing/non-throwing variants it would work, but I believe the stated goal of rethrows is precisely to avoid such overloads.

3 Likes

Yes, exactly. I wrote about this in the previous thread as a reply to @Joe_Groff 's proposal of Uninhabited protocol and its inference.

2 Likes

I agree. But on the other hand, if we build a relationship between errors it will be the same as "WrappedError" example. And I think if someone deliberately throws an error type independent from the source type, it's because the author don't want this relationship to exist. I don't think it's a good API design approach, but in general this should be possible in the language, and "two overloads solution" is good enough.

It's actually much simpler than I thought, with that added, itā€™s just as @dmt originally wrote it:

struct WrappedError<Wrapped: Error>: Error {
  let wrapped: Wrapped
}

func f<E>(g: () throws(E) -> Void) throws(WrappedError<E>) {
  do {
    try g()
  } catch {
    throw WrappedError(wrapped: error)
  }
}

That's tidy.

How would this magic detection work if WrappedError were a type imported from a different, resilient module?

Also, how could struct Result be defined in a way that doesnā€™t cause Result<_, Never> to become uninhabited?

1 Like

Whether a type is uninhabited or not is derivable from its layout, so if it's frozen we can look at the layout. But more generally, if we had a layout constraint for it, then maybe you could explicitly declare extension WrappedError: Uninhabited where Wrapped: Uninhabited {} as an explicit guarantee that that's true (and have the compiler check that it is in fact true).

Result is an enum, and as long as an enum has one inhabited case, the enum is inhabited. When you hear functional programming nerds talk about structs and enums as "product types" and "sum types" this is one reason why: a struct has the product of all possible states of its fields, so if any field is uninhabited (zero) then zero * n == zero. An enum has the sum of all possible states of its payloads, so if any field is uninhabited that doesn't affect the habitation of its other payloads, because zero + n == n. If you were going to write an Uninhabited declaration for Result, it would have to be both where Success: Uninhabited, Failure: Uninhabited.

17 Likes

So you want something with a behavior like this?

// hypothetical syntax:
typealias SomeErrorHelper<E> = if E == Never { Never } else { SomeError }

func f<E>(_ g: () throws(E) -> ()) throws(SomeErrorHelper<E>) { ... }

Maybe we should look into enabling very simple type logic to work like that. Not in this pitch of course but as a general direction it could be interesting.

2 Likes

Thank you!

I've said it before, but I want to reiterate that this has been a particularly insightful and educational discussion. For me at least. But I suspect others also didn't know about a lot of these use cases, or a lot of the techniques people have developed.

The Original Error Handling Handler

I've been re-reading the original Error Handling Rationale documentation, by @John_McCall (I thinkā€¦? Git history suggests so). From circa Swift 2.

I actually don't recall it being mentioned before in this thread, so for anyone who hasn't read it, it's well worth it. It's very well written and although nominally for the Swift team itself, I find it very approachable to the lay-programmer.

It's particularly amusing to read its section on "Typed propagation" where it lays out the argument for why Swift should require marking of throwing functions at all (the throws keyword in function declarations, to be clear). And similarly why try should be required. Of course now these are all just givens and obviously wise design choices, but keep in mind that at the time it was written no mainstream programming language adopted that approach (to my knowledge).

Specificity of Typed Throws

And there's one subset of that in particular that I think is relevant to the topic of typed throws:

The alternative is to say that, by default, functions are not being able to generate errors. This agrees with what I'm assuming is the most common case. In terms of resilience, it means expecting users to think more carefully about which functions can generate errors before publishing an API; but this is similar to how Swift already asks them to think carefully about types. Also, they'll have at least added the right set of annotations for their initial implementation. So I believe this is a reasonable alternative.

But then:

The last question is how specific the typing should be: should a function be able to state the specific classes of errors it produces, or should the annotation be merely boolean?

Experience with Java suggests that getting over-specific with exception types doesn't really work out for the best. It's useful to be able to recognize specific classes of error, but libraries generally want to reserve flexibility about the exact kind of error they produceā€¦

I find it incongruent with itself. It uses essentially the same argument - "this requires forethought and mistakes may be locked in forever" - to argue both sides. In the first, that functions should have to opt into throwing if they ever might want to throw, but then in the second that functions should not specify what they actually throw because then they might be unable to change it.

I think it's fair to insist on consistency here. Either we err on the side of flexibility - removing the throws keyword so that functions may change their mind in future - or we err on the side of programmer empowerment - allowing them to trade that flexibility for specificity (or other benefits, like eliminating runtime exception-handling costs, or existentials).

In fairness to that original document, it does go on to present other arguments too (e.g. the problem with wrappings, that's also been raised in this thread). I'm not going to reiterate or re-debate those here, as I think they're already well-covered within this discussion thread.

Really, what I'm getting at in bringing this up again is that for written guidance on when to used typed throws and on what type to choose, I really encourage everyone to focus on providing rationale, not dogma. Just as in a good style guide, a persuasive argument for a guideline is much more useful than a blunt "rule".

The current proposal draft is pretty good in this respect - When to use typed throws and Effect on API resilience are both mostly well-written explanations of the trade-offs and pitfalls (if a little incomplete). But the former does lean a little bit into prescriptivism with statements like "The loadBytes(from:) function is not a good candidate for typed throws" and "Typed throws is potentially applicable in the following circumstancesā€¦" and then some bullet points which are somewhat but not entirely self-explanatory. I think it paints an overly-restrictive picture of when typed throws may be used. It also completely omits mention or consideration of domains like Embedded Swift where typed throws might be appropriate in a broader range of circumstances (like a loadBytes(from:) method).

I expect these sections of the proposal will also form the basis of updates to the Swift Language Guide, which is where the audience gets much bigger and this really matters.

I almost didn't bring this up, because it feels like it might appear a bit nitpicky, but I do think it's important to write with reader agency in mind and focus on education more than codification. Especially for things written by Swift team members or in official Swift documentation, because their position imbues a degree of authority whether intended or not.

11 Likes

With regards to when and when not to use typed throws thereā€™s also a long and detailed historical post that drills down into specifics that largely arrives at the same conclusions reached in the proposal: in most cases itā€™s probably better to throw any Error, but, reluctantly, itā€™s needed for certain use cases.

I feel the current proposal does a good job of summarising the cases where itā€™s needed. i.e. generic expressivity, embedded/real-time applications. But maybe could benefit from examples where you shouldnā€™t use typed throws. i.e. most places.

One particular use case that stood out for me as something that may benefit from highlighting as an anti-pattern was using typed throws to encode a makeshift call stack, i.e. creating a breadcrumb trail up the call-stack through the use of typed, nested errors.

Now, while this is a perfectly valid use case (having something akin to a stack trace to diagnose exactly where something might have gone awry is a useful thing) perhaps providing a lower friction community-blessed alternative (i.e. consistent across third party modules) would go someways to eliminating this potential anti-pattern.

How this would manifest, I donā€™t know. One option could be to include a Foundation Error type which does include a underlyingError: any Error? property, or another could be something enabled by the runtime, maybe by some top-level annotation, which facilitates the accumulation of a stack trace equivalent up each of the ā€˜throwā€™ sites.

Something else that will help greatly with this is the approval of SE-0409: If a module imports a third-party module internally, theyā€™ll be nudged when they accidentally ā€˜encode the dependency treeā€™ by including a third-party Error type as a part of their own public Error.

Now, all of this might sound a little prescriptive, but personally thatā€™s something I appreciate in a language. Itā€™s liberating to have one ā€˜blessedā€™ way of doing things rather than needing to learn dozens of equally clever alternatives. The smaller surface area is a boon to creativity.

1 Like

The way I've been thinking about typed throws is a a bit different: typed errors are great (and probably preferred) within a module. Like enums, they can aid you in enforcing exhaustivity and managing expectations within your own code. However also like enums, they're probably not the best when it comes to crossing a module boundary as public API. (Again, like enums, there are cases where it's the right call, but have the same sort of constraints as using enums for public API: dealing with a closed set of possible values).

9 Likes

I don't think this is necessarily contradictory, for a few reasons.

It's perfectly consistent to argue that it is more common for a throwing function to change the set of errors it wants to throw than it is for a function which initially has no error conditions to suddenly start wanting to throw an error. I don't have the empirical data on hand to argue that case, but it seems like a reasonable position.

There's also an angle from the use sideā€”defaulting all functions to throwing would be an opinionated choice of the language that functions should assume they may throw unless one affirmatively commits otherwise. I suspect this would have the effect of making many more functions throwing than we have today. This in turn would effect the set of solutions that are acceptable on the handling side of things. We'd either be imposing a very high annotation burden by asking users to mark every potentially-throwing call with try, or else end up with a regime where error handling are passthrough-by-default (after all, every function is throwing-by-default anyway).

I think both snippets you've quoted point to a single coherent conclusion: the zero-to-one step in number of thrown errors is more reasonably viewed as a difference in kind, whereas the one-to-N step is more reasonably viewed as a difference in degree. While this conclusion is certainly arguable, I don't think it's inconsistent.

6 Likes

Hence an open set or non-exhaustive enums would aid here for the use of a "dependency" or imported module/package/library. You'd maintain the benefits of typed throws. Swift comes across this case with C enums and you have to use the @unknown default case. You can currently cheat and create enums like this in C but they do not have associated values like Swift enums.

From an API design standpoint: if you have a resilient layer for a public interface that is not inlined into the caller that can itself create some sort of failure that is a good layer to avoid concrete thrown types.

So if you have a function defined as:

public func frobulate() throws -> String 

If there is not @inline(__always) and/or it is not on a @frozen type (i.e. you want to be free to change the implementation) the fewer requirements on your implementation the better. With no typed throws it only remains that frobulate exists, that the signature is throwing and that the return type is a String. It could throw in 1.0 of a framework an NSError, then in 1.1 throw a FrobulationFailure and in 2.0 throw a FrobulationFailure or a CancellationError.

On the flip side; if you have the function completely visible for its implementation to the caller (to support inlining) then the signature is reasonable to consider if typed throws is appropriate. Likewise if the source of throwing is guaranteed to be known to exist from a given source; for example a closure that may have a distinct throwing type then it is also reasonable to make it have the mirrored throwing signature.

func withBruddle<E: Error>( _ apply: (Bruddle) throws(E) -> Void) throws(E) -> Void

There is a similarity in spirit to closed set enumerations versus open set enumerations. In the rethrows-like behavior of withBruddle it is a known closed set: the with* function can only throw what the apply can throw. The advantage here is that the information of what it does is not lost when traversing through an application of the throwing closure at the cost of enforcing strict behavior of what that with* function does.

Personal speculation: ABI/API stable APIs won't use typed throws for root level sources of failure, non-ABI/API stable APIs may use them. Furthermore, constrained environments that have discrete known failure modes and are sensitive to the existential costs might also consider typed throws at the root of actions. Things that rethrow (either by protocol conformances, or by closures) will likely re-emit those failures where possible. Likewise, I would expect that APIs that violate that expectation (throwing from something that ought to consider a source of rethrowing) might need some refinement.

In short I think that typed throws will be exceedingly useful in certain scenarios, but it will be something that will have to be considered carefully when designing for ABI/API stability. And perhaps those discussions are something that should be had when designing things; who uses the error type and how?

2 Likes

Precisely.

Good conclusion, but odd start: :slightly_smiling_face:

Better for whom? Certainly not the users of this API.

Every aspect of API design is trade-offs between forward-compatibility and specificity. There's nothing special about errors in this regard.

Each case should use whatever compromise suits it best, whether that be a frozen enum, an open enum, a struct, a protocol (and then either opaquely or existentially), etc. Same as for its non-error return type, its parameters (e.g. take Strings specifically or genericise over StringProtocol? Int or FixedWidthInteger, or BinaryInteger?), and whether it returns an optional or throws at all (maybe it doesn't now, but might want to in future?).

Not to mention the structural elements - should you use structs (and if so expose them directly, or opaquely?) or should you use classes? Enums or mimicry thereof with static member variables? And so on. Structs are directly exposed all the time in so-called stable APIs, and it works fine, because not everything is likely to change.

And "breaking" compatibility is not the bogeyman some make it out to be, anyway. Apple's "stable" APIs "break" all the time, intentionally (mostly). Functionality changes (generally improving), bugs are fixed, etc. Sometimes that's done within the existing API, sometimes it requires new overloads, sometimes new APIs entirely. Users adapt and progress and as long as the "breaks" have justification, everything moves forward with at worst a little griping (and then, mainly of the "ugh, reality is annoying", not "this was a bad choice").

3 Likes

Aside from the new ABI impact of having an error type in the function definition, it doesn't seem like there are any new considerations typed throws requires beyond what having a public error type in the first place already does. In fact, starting with typed throws seems superior in pretty much all cases to me.

  • With a public error type you already have to deal with breaking exhaustiveness when using an enum or users missing cases when using a struct.
  • With compiler-checked error types, if you use an API below your public surface that produces untyped errors, the compiler will tell you about it, requiring you to either untype your error or handle that fact in your typed error. Otherwise this is just a silent change you may miss, on both sides.
  • Developers can easily erase the public error, either automatically or by overloading.

Really the only thing the language needs is (the equivalent of) @frozen enums in non-stable modes and the API evolution concerns would be completely solved. In that case, even if you did change your underlying implementation to produce a new error and you didn't already have a fallback case in your typed error, you'd be able to easily add cases to your existing error.

In particular, I'm not sure this is really true:

Changing the underlying type of an error is a major API breaking change, as users of 1.0 who updated to 1.1 would lose any error handling they had for NSError. Currently Swift can't surface these changes in the language, but they're still breaking at runtime, which is why defaulting to typed errors would be the better start. At best the consuming devs would have an "unknown error" case to handle casting failures and catch, but that assumes a lot from them and generally means a degraded error handling experience anyway.

4 Likes