[Pitch N+1] Typed Throws

Looks great.

Even though the implementation is quite non-trivial, all the rules and behaviours are intuitive and straightforward, which I think is a good sign that this is an elegant solution to a complex problem.

Multiple error types

I'm still very interested in the potential future expansion of this to better support throwing multiple types of errors, but I think the proposal as written stands well on its own and is clearly a big improvement even with those limitations. Plus, it still allows multi-type errors, just without syntactic conveniences (i.e. one will still have to manually define either union protocols or union enums, but at least it'll now be possible).

And I'm increasingly convinced (from discussion in Structural Sum Types (used to be Anonymous Union Types) and the proposal's succinct summary of the topic) that language-wide support for sum types is the best way to enable multi-typed throws anyway, which is technically orthogonal to this proposal and looks like it would compose naturally (requiring only a little error-specific support in the compiler, maybe, such as around exhaustiveness checking of catch statements).

AsyncIteratorProtocol

However, the suggested change to AsyncIteratorProtocol might not be able to be made in a manner that does not break ABI stability.

Is this implying this enhancement might not actually happen? If I recall correctly, the current position on ABI breakages is "no", even for Swift 6?

CancellationError

Maybe I missed it, but I didn't see the proposal mention how this will work with failable async sequences that also support cancellation? It should be called out, if indeed there is a problem there as others have suggested earlier in this thread.

It's still unclear to me how CancellationError is supposed to work in Swift, in general. Apple's own APIs don't use it consistently, and there's no apparent guidance on when or why it should be used vs just returning quietly. That lack of official clarity makes it hard to judge whether it's a flaw that the proposal would basically preclude the use of CancellationError in failable async sequences, or just by happenstance forcing a particular 'style'.

Effect on API resilience

I find this section a little hard to parse, because of the run-on sentences. Although if I pretend it's read aloud by Captain Kirk, it's at least entertaining. :grin:

Perhaps it could be written more simply and tersely, e.g.:

  • Favour (non-frozen) enums for your error types, as opposed to structs, so that you can add additional error cases in future.
  • Avoid use of typed errors in situations where errors are inherently unpredictable or beyond your control (e.g. app extension points / plug-in APIs).
  • Prefer opaque error types over concrete error types (e.g. throw some CustomErrorProtocol) if you need to ensure thrown errors have certain functionality, like a backtrace property, but it's not important to the caller what the specific error type is).
  • In libraries with Library Evolution enabled (resilient libraries) be particularly careful about using typed errors, and err on the side of not using them.

I think that's going too far. By that same logic you could say the compiler should reject concrete parameter types and return values too.

I agree that a lot of the time resilient library APIs should perhaps not use concrete error types, but some resilient library APIs truly are straight-forward and have no plausible need for different / expanded error types in future (e.g. numerics libraries which realistically aren't going to suddenly start relying on the network for matrix multiplies or whatever).

Your example as written doesn't seem to permit compile-time exhaustiveness checking, because the error domain is fundamentally just an arbitrary string…?

Subjectively I'm also averse to it on appearance, because it seems like a regression to NSError which I know from experience is a pain to work with. Plus at best it seems like it's just reimplementing dynamic casting, in essence?

As noted here, the type of the error thrown out of the do block is any Error, so this code would be ill-formed.

Yes, that's a good point: the proposal should describe the it's appropriate to use typed throws. Here's where I think one should use typed throws:

  1. In dependency-free code that will only ever produce it errors itself.
  2. In code that stays within a module/package where you always want to handle the error, so it's a purely an implementation detail.
  3. In generic code that never produces its own errors, but only passes through errors that come from user components. The standard library is like this.

Any time a public API will (or may in the future) thrown an error produced by one of its dependencies, it should use untyped throws. In general, most APIs are expected to use untyped throws.

Yes, it's needed to prevent ambiguities, now and in the future. Two examples:

  • We might invent more effects at some later point in time, at which point throws SomeError would become ambiguous with throws someneweffectname.
  • We use the general type grammar production for the error type, because one can throw something like any Error & Codable. That means we have a syntactic ambiguity with function types. Given:
    func f() throws () -> Int { }
    
    are we throwing () and returning Int, or are we throwing () -> Int?

Additionally, there is a consistency argument: all attributes and modifiers use parentheses for their arguments, so we should do the same for effect arguments. If we invented more effects that had arguments, there is absolutely no guarantee that they would also have a single argument (vs. multiple arguments), at which point that effect would be inconsistent with throw SomeError. Plus, throws any Error & Codable is really confusing to read without parenthesizing the thrown type.

Yes, you could carve out sneaky rules to make it possible to elide the parentheses, but I think we'd come to regret doing so.

I'll add this to the Alternatives Considered.

IMO, you don't: you're making use of different subsystems with different potential failure modes, so you shouldn't try to enumerate all of those failure modes. This is a case for untyped throws.

You could do that, by wrapping up the error you get in a CatError.

If it's something like this, where you're calling an API using typed throws and then trying to match against an obviously-unrelated type, the compiler could complain:

do {
  try feedCat() // throws CatError
} catch let e as URLError { // a CatError is never a URLError, complain
}

I thought it was clear, but sure---I'll amend the proposal to make this more clear.

No. I don't think we should have separate rules for calling vs. type identity, because it's likely to result in strange inconsistencies in the language.

As noted above, I'll add this to alternatives considered, but IMO the problems with the unparenthesized syntax are significant enough that we should require parentheses. (And I don't think we should invent new syntax like <> here)

It supercedes the use of @rethrows on AsyncSequence for the purposes of for...in, which is in effect the only place @rethrows works today. However, it does handle the more general notion of "rethrowing protocol conformances" that a properly-specified-and-implemented @rethrows would.

Yes, that's fine.

I hadn't thought of these. Yes, we can look into them.

Well, these AsyncStreams will need to accommodate CancellationError, e.g., by using any Error as their failure type.

Yes, it's meant to be allowed. I'll add some more discussion of this.

I tend to prefer the simpler rule of falling back to any Error, primarily because it's easier to reason about and more efficient to implement (especially when we need to do it at runtime).

You'll need two different do...catch blocks (or some abstraction over them) to translate into a common error type. This is a case where untyped throws is the ideal situation, but the abstraction is too costly for an embedded environment.

I think this should be a matter of convention that isn't enforced by the language. A library that is built resiliently in some platforms could nonetheless meet the criteria I set out above for using typed throws, and I think it's fine to use typed throws there.

I think we might want this as an option, i.e., if there's a library that is resilient and meets the criteria for using typed throws (ahem, CryptoKit, I'm looking at you), it might be fine for them to adopt typed throws retroactively---but using the existing ABI.

Swift 6 cannot break ABI. It's going to take more implementation experience to know whether we can make the change as specified here, hence the cagey wording.

Doug

15 Likes

I think that would be a great QA to have the possibility to exhaust all possible catches in case like this, which I think would be very common

As the proposal notes, determining whether a type is visibly uninhabited is also hard to specify. As another possible future direction, if we had an Uninhabited type constraint, which only uninhabited types could conform to, I wonder if that would help, since it would give us something specific in the language to build rules like this on top of. I don't think we need to front-load that as a dependency, though; if we add it later, and let error checking take advantage of it later, it seems like the worst that would happen to existing code is that it might get warnings about now-unnecessary try markers and catch blocks.

7 Likes

Just to expand on this a bit. The problem that I have seen when designing APIs that vend AsyncSequences such as the once for AsyncHTTPClient. It is quite important to indicate to users if the consumption terminated due to cancellation or due to the sequence finishing. In the case of AsyncHTTPClient, this allows the user to differentiate between their consuming task got cancelled or the remote stopped sending data.
More importantly, it isn't enough to just check for cancellation after the for await in loop since this might race and you might get false positives. Some code to illustrate this:

for await element in someNonThrowingAsyncSequence {}

// Did we end up here because of task cancellation or because the sequence terminated?
Task.isCancelled // This might have become true right after the sequence terminated but before we checked here

What I think is important to consider here is that we now leave it up to the vendor of an API to decide if the AsyncSequence should be throwing which takes away the possibility of the consumer to ever find out what the reason for termination was. Whereas if every AsyncSequence would be throwing that would mean users can always choose to check for the concrete error that was thrown.

Task.sleep is similar here but it only ever throws one type of error - CancellationError - so it can solve this easily.

I think we have three choices here:

  1. Make all AsyncSequences throwing and require all AsyncSequences to throw a CancellationError when they terminate due to task cancellation.
  2. Introduce types throws to AsyncSequence. In this case, I think we should never throw a CancellationError from root asynchronous sequences such as AsyncStream even if the Failure type is any Error.
  3. Root asynchronous sequences such as AsyncStream to introduce an enum that is either Failure or CancellationError and consumers can then use algorithms to transform the error. This is basically a limited scope explitict union type.
2 Likes

I would suggest either not including this guidance at all or not using "should" in any of these statements, as it seems overly strict. As far as I can tell there only a single downside to publicly exposing a thrown type: it locks your error type, thereby potentially creating ABI and source impacts if you need to change it in some incompatible way, like adding an enum case. Leaving aside the fact that allowing @frozen enums for unstable modules would alleviate most of that issue, there doesn't seem to be another issue for the consumer here, as calling these APIs in contexts without an error type would have them automatically erased anyway. Additionally, recommending that public APIs don't expose error types seems to contradict one of the primary use cases for type throws in the first place: making error types visible to developers. You cite such cases in the proposal itself.

There are specific error types in typical Swift libraries like DecodingError, CryptoKitError or ArchiveError. But it's not visible without documentation, where these errors can emerge.

On the other hand error type erasure has its place. If an extension point for an API should be provided, it is often too restrictive to expect specific errors to be thrown. Decodables init(from:) may be too restrictive with an explicit error type provided by the API.

This guidance seems more correct to me, and its more precise language allows for more nuanced cases.

Personally I would say any Error is a good starting point but that more nuanced consideration for publicly typed throws should be considered based on the various criteria raised elsewhere in the proposal.

2 Likes

This sounds like a good approach to me.

Love it!

This seems like yet another great reason to also revive the guard-catch proposal. The ceremony that encourages grouping multiple try statements in a do block will be that much more harmful when it forces this error type erasure. This is much worse when the throwing expressions yield a value that you need in a later step:

do {
    let filename = try lookUpFileName()       // throws ConfigurationError
    let data = try readFile(named: filename)  // throws FileSystemError
    let result = try processData(data)        // throws DataProcessingError
} catch {
    // any Error :(
}

(ETA: particularly in embedded Swift, where I assume the above do block will yield a compilation error.)

9 Likes

I'm not a fan of this. I use plenty of async sequences where cancellation is expected - indeed, sometimes it's the only way to terminate a sequence such as for infinite generators. Today I tend to just have them quietly conclude when cancelled, as then the code best matches the semantics - there's no "trying" and failing, you just enumerate until the sequence stops.

Cancellation is almost always an explicit, intentional action, in my experience. It's either something I'm doing explicitly in my own code - where it usually just means "this task is no longer relevant, make it go away" - or triggered implicitly by library code or user action (e.g. "the associated view has gone away, so just end this background task"). In almost no case is cancellation actually an error - if there is a genuine error triggering it, then I care about that error, not CancellationError - and it's certainly not something you can "handle" in the sense of retrying or similar. So it's weird to me that it's handled through an exception to begin with. It's almost always just a special exclusion check, "oh, it's CancellationError, don't do error handling".

Re. CancellationError more broadly, URLSession is a notable example of what this proposal is likely to make worse, which is that it does not throw CancellationError but instead picks a worst-of-all-worlds approach whereby it wraps cancellation into its own bespoke cancelled, ensuring no generic code is ever going to correctly handle cancellation from URLSession-using code.

This highlights why multi-typed throws is important, because many async APIs will have to rewrap CancellationError just like URLSession does, replicating this anti-pattern.

So yeah, I think this proposal is great and should be accepted, but I do think it leaves a lot unsolved for async code.

9 Likes

How about

func foo() throws: FooError {}

Afaics, this has no ambiguity, and a colon followed by a typename is not that uncommon.

Another place where I think the standard library should adopt typed throws is in Task.sleep. This is documented to only throw CancellationError and this is the only error that would ever make sense for it to throw.

10 Likes

For starters, I think that CancellationError is a horrible misnomer that stems from the fact that everything that can be thrown in Swift must be an Error. It's not actually an error and it would be much nicer if it were called something like CancellationNotice, but unfortunately that wouldn't fit with Swift's convention for throwable types at all...

It can be quite useful though. There are plenty of examples where you'd want to handle task cancellation specially...maybe you want to clean some resources up before stopping the task, or you still want to finish the task but take some shortcuts now that you know the task is canceled.

Maybe it's too late for that, but ideally, I'd like to have this type in the standard library:

@frozen
public enum TaskError<E>: Error {
    case cancellationNotice
    case error(E)
}

This would allow all functions that would have previously thrown a CancellationError or a custom error wrapping it to use a common error type. Even functions that would normally throw an untyped error (e.g. UrlSession.data) could instead throw a TaskError<any Error> instead, signalling that they could also throw a cancellationNotice. Task.sleep could throw a TaskError<Never>. This would also solve the problem that you raised regarding cancellation handling in generic code.

5 Likes

A concern I have is that the thrown type of the function is required to conform to Error. I think there are two potential problems with that.

First, doing so unnecessarily requires any thrown type to conform to Sendable. This makes sense with Error because any Error is a "currency type", and conforming it to Sendable aids interoperability with concurrency. But with typed throws, this becomes unnecessary, since the compiler can always statically determine if a given error type is Sendable or not.

Second, we can expect some users to make "helper enums" with something like this:

enum MyError {
    case someSpecializedError
    case decoding(DecodingError)
}

In cases like these, it's almost always wrong to promote MyError to any Error, because code that would normally handle a DecodingError would fail to do so. Additionally, we might not want users to throw the .someSpecializedError case outside of some specific domain. Making MyError not conform to Error would prevent that.

4 Likes

I do like the idea that an async function (or task) could explicitly indicate whether it's cancellable or not (e.g. in the above example, by whether it throws TaskError or not). Although I'm not sure I like having to 'unwrap' all the actual errors.

Really the result of an async operation is a triplet: success, cancelled, failed. One can see how folding the cancelled case into the failed case is a relatively minor semantic change and certainly convenient (most of the time).

I have this vague notion in my head that cancellation could be its own path - separate from actual failure cases - but I'm not sure what that would look like. Maybe, very roughly - for the sake of getting something down in pixels:

async func fetchCat() throws cancellable -> Cat {
    while true {
        guard let cat = try grabCat() else {
            Task.endIfCancelled() // Could also Task.sleep(), same effect.
            continue
        }

        return cat
    }
}

…

do {
    _ = try await fetchCat()
} catch {
    // Handle actual error.
} cancelled { // Optional, just like `catch` can be if you're happy with
              // the default of just ending this function and passing
              // the cancellation up the callchain.
    // Handle cancellation.
}

Kind of like how in some places today you can install a closure as a cancellation handler, only in a more imperative form (much like Structured Concurrency replaces callbacks more generally and really streamlines the code).

In some sense it's basically equivalent to what we have today, or to the TaskError example, just with syntax sugar for the cancellation case. But it is appealing because most of the time cancellation just means bailing out recursively up the callstack - it's rare that you need to do anything specific to handle it, or at least anything that can't be handled with a defer anyway.

It's essentially a special-cased multi-type throw, privileging CancellationError above all others (which is arguably fair, given how ideally all async code should be cancellable). So by that token I'd still rather just have sum types, or at least multi-typed throws, but maybe this more limited approach is more appealing to folks that really don't want (generalised) multi-type throws?

1 Like

And then someone invent a function that could be cancelled for a number of different reasons that's nice to handle on the callsite. And we will debate about how to specify the reason: cancellable vs cancellable(any Reason) vs cancellable(ConcreteReasons) vs ... :wink:

2 Likes

In fact I have been pondering if cancellation should have an associated value, a "reason" of some kind. But I think that's orthogonal in any case. And I can't say I've actually hit a real-world need for that.

1 Like

I liked this, but I think it will fail in general case, like (T) -> U conforms Error and T is also conforming Error.

// throws (any Error) and returns (any Error)
// or, throws (any Error) -> (any Error) and returns Void
func foo() throws: (any Error) -> (any Error) {}

Of course function types do not have such conformance, but in the yield case mentioned above this is more serious ambiguity.

Protocol conformance and refinements

Protocols should have the possibility to conform and refine other protocols containing throwing functions based on the subtype relationship of their functions. This way it would be possible to throw a more specialised error or don't throw an error at all.

All this discussion of parity between Result, Task, and typed throws, alongside the minor source-breaking changes we're willing to incur in Swift 6 to deliver on the overall feature (all of which I vehemently agree with), makes me sad that it's likely not in scope here to have the same subtyping niceties when it comes to protocol requirements and that other error handling mechanism we have: optional return values.

My pet use case has the same rationale as expressed here: if I have an infinite sequence, it would be more expressive if I could implement the iterator requirement as next() -> Element instead of next() -> Element?, but alas this is not regarded as kosher in present-day Swift. It does seem of-a-kind to what we're dealing with here, though, since returning an optional Element? is notionally isomorphic to throws(Void) -> Element, for which this proposal would accommodate subtyping the relationship...

5 Likes

This is an excellent idea, I love it!

Can/Should Void conform to Error? Maybe with Slava's tuple conformance proposal?