[Pitch N+1] Typed Throws

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?

Thanks for the updated pitch @Douglas_Gregor !

Tasks

It'll definitely help with Task and friends a lot which are right now a bit weird in their lossy nature surrounding errors.

Documenting intended usage

I know that "we know" but I think this feature needs to become incredibly well documented that this should be used very rarely, in very specific situations (when one can guarantee no other kinds of errors will ever need to be thrown). We sometimes tend to omit detailed instructions in the TSPL, but this feature really really needs to get its own big section with explanations when not to use it. (Which then people will miss to read, but that's the best we can do heh :sweat_smile:)

I'm sure people will absolutely foot-gun themselves and lock themselves out of evolving APIs without source breaks though anyway... but I guess that's the price we're willing to pay here.

Task creation and completion

We might want to stop and think a bit before doing this unification:

@discardableResult init(... () async -> Success) where Failure == Never
@discardableResult init(... () async throws -> Success) where Failure == any Error

These two initializers can be replaced with a single initializer using typed throws:

init(priority: TaskPriority?, operation: () async throws(Failure) -> Success)

As there have been a number of requests to consider removing the @discardableResult from the throwing versions of those initializer. It is a fair argument to say that these are sneakily hiding errors:

Task { // don't store the task
  try // totally going to miss this error
} 

so avoiding the unification of those inits, we could consider keeping the @discardableResult only on the where Failure == Never version of these initializers.

This way:

Task { try ... } // warning, dropped reference
Task { ... } // no warning, one-off shot task is not as incorrect

(ok) Throwing "some Error"

Hah, I had just written up about how that might be useful -- but then I got to the point where the proposal covers it -- all good then :slight_smile:

Distributed actors

Yes this will help somewhat with distributed actors where authors want to THROW errors and have them be transported back to callers. In practice, people do this today but rely on as? Codable casting of the errors. It will be nicer if we can enforce typed errors to conform to the SerializationRequirement at compile time:

// DAS.SerializationRequirement = e.g. Codable

distributed actor Test { 
  typealias ActorSystem = DAS
  distributed func test() throws(SpecificError) {}
  distributed func test() throws(BadError) {} 
  // error, does not conform to DAS.SerializationRequirement
}

struct SpecificError: DAS.SerializationRequirement, Error {} 
struct BadError: Error {} 

call sites will work fine in practice and not source-break because of the errorUnion(SpecificError, DAS.DistributedActorSystemError) -> any Error and if we'd ever actually form type unions here (I'd personally enjoy that, but I understand the type system implications so won't necessarily push for that), it'd also work out nicely.

(minor) Minor typos


Overall this looks good and I'll have to take a stab at integrating it into Distributed soon I guess.

5 Likes

In the event that generic constraints for error handling become a reality, it would significantly enhance the expressiveness of the language, particularly at the library level. As Xiaodi mentioned, this development prompts us to reconsider and simplify the fundamental protocol requirements.

Potential future direction

If we extend the concept of generic constraints for throwing effects, it begs the question: what if we introduce generics for asynchronous effects as well?

Taking this idea further, it becomes possible to generalize the Sequence protocol to accommodate AsyncSequences seamlessly.

public protocol IteratorProtocol<Element, Failure, Asynchronicity> {
  associatedtype Element
  associatedtype Failure: Error = any Error
  associatedtype Asynchronicity = Never
  mutating func next() async(Asynchronicity) throws(Failure) -> Element?
}

public protocol Sequence<Element, Failure, Asynchronicity> {
  associatedtype Iterator: IteratorProtocol
  associatedtype Element where Iterator.Element == Element
  associatedtype Failure where Iterator.Failure == Failure
  associatedtype Asynchronicity where Iterator.Asynchronicity == Asynchronicity
  consuming func makeIterator() -> Iterator
}

In this context, Asynchronicity == Never could signify synchronous operations, similar to how Failure == Never denotes non-throwing operations. For other generic values, they might indicate asynchronous behavior, although this might be meaningful only for types like Void. It's worth considering whether there might be an excess of primary associated types.

This approach of using generic signatures is not unprecedented. Rust, for instance, employs lifetime annotations in function signatures and type declarations, harnessing the language's generic capabilities effectively.

Introducing generics of this nature could facilitate the creation of a generic map operation that can handle synchronous, asynchronous, throwing, and non-throwing scenarios with the same function signature, opening up new directions for reasync.

public func map<U, E, A>(
  _ transform: (Wrapped) async(A) throws(E) -> U
) reasync(A) rethrows(E) -> U?

Moreover, it opens up the possibility of consolidating very similar implementations, such as DropWhileSequence, AsyncDropWhileSequence, and AsyncThrowingDropWhileSequence.

This idea is presented as a starting point for consideration. Please approach it with a degree of skepticism or explore it further. I acknowledge that implementing such a concept in the compiler would be a substantial undertaking. However, it offers a concept for structuring our effect system within manageable constraints.

While I don't intend to divert this discussion, the prospect of generic constraints for error handling has prompted me to reevaluate the broader implications of generic constraints.

2 Likes

I have been thinking about this a bit as well w.r.t. AsyncSequence and it would be really nice if Sequence and AsyncSequence would gain final values. This is orthogonal to typed throws though.

protocol Iterator<View, Failure, Final> {
    associatedType View
    associatedType Failure
    associatedType Final

    func nextView() throws(Failure) -> (Final?, View)
}

This would allow you to to model infinite sequences, throwing sequences and any combination of both.