[Pitch N+1] Typed Throws

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.

I’m seeing some dislike for the throws(ErrorType) syntax, could we bend the where clause to accommodate? I'm not fully sold on locking Error as the keyword, but it's the best I have right now:

func run() async throws -> Void where Error == HTTPServerError {
    // Do your thing
}

Lends well to generic constraints too:

func retrieve(_ record: some RecordProtocol) async throws -> some SQLResponse where Error : SQLError {
    // consult database and configure result
}
Source Breaking

This is source breaking for any throwing method using Error as a generic type name. That's likely a small group considering most would want to avoid the ambiguity of using Error generically for a value they also want to conform to the Error protocol (I've seen Err a lot for that use case.)

2 Likes

But you're leveraging the fact that func f() throws(SomeError) -> Result and func g() -> R where (R == Result) or (R == Result?) are both binary. Which is true for finite and infinite sequences, and such design makes a beautiful API. But it doesn't scale well. For example, if you have a protocol with a function, that results in one of combination of three possible outcomes, you wouldn't be able to map it on throws(Error) -> Result with the same clear distinction.

/* pretend this is a sealed protocol */
protocol ThingResult {
}
struct ThingResultA: ThingResult {}
struct ThingResultB: ThingResult {}
struct ThingResultC: ThingResult {
  var associatedValue: Int
}
enum ThingResultAB: ThingResult {
  case a, b
}
enum ThingResultAC: ThingResult {
  case a, c(Int)
}
enum ThingResultBC: ThingResult {
  case b, c(Int)
}
enum ThingResultABC: ThingResult {
  case a, b, c(Int)
}

protocol Thing {
  associatedtype Result: ThingResult
  func getResult() -> Result
}

+1 I've wanted the ability to strongly type errors for ages. This is a very well written pitch. I can tell you've been doing this awhile :).

Does it make sense to include a pattern matching shorthand for cases there is a single error caught? Maybe a future direction?

do {
  try f()
} catch {
  switch error {
  case .error1: print("error 1")
  case .error2: print("error 2")
  default: print("something else")
  }
}

To just this for the strongly typed error only.

try f() catch {
  case .error1: print("error 1")
  case .error2: print("error 2")
  default: print("something else")
}

Untyped exceptions handled as usual:

do {
  try f() catch {
    case .error1: print("error 1")
    case .error2: print("error 2")
    default: print("something else")
  }
} catch {
  print("An untyped error occurred.")
}

EDIT: Oops, the examples were swapped.

1 Like

catch is already a thin veneer over a switch on the error value, so I would expect this to work:

do {
  try f()
} catch .error1 {
  print("error 1")
} catch .error2 {
  print("error 2")
} catch {
  print("something else")
}
7 Likes

One other suggestion. I think it might be better to have the compiler warn about unimplemented errors for typed errors. I think explicit warnings about unimplemented cases is less surprising to someone learning Swift and I think it would help to catch bugs.

I get that it might be desired to keep the behavior that is proposed to prevent breaking code if error types are added to existing functions that previously used "any Error" since they might now get compiler warnings about unimplemented cases in pattern matching. I think this features breaks ABI either way, but I could certainly see the ABI issue to be easier to workaround by transparently changing it to "any Error" in Apple frameworks.

I would still be +1 either way. If you knew that all error states should be caught for a scenario, there is always the option to put a switch inside of the catch.

In a perfect world, I would prefer to have the compiler assert that all known cases are checked:

do {
  do {
    try f() // MyTypedError
  } catch .error1 { 
    print("error 1")
  } catch .error2 {
    print("error 2")
  }
  // Compiler will warn about unimplemented .error3
} catch {
  print("catching .error3 as any Error")
}

Allow fall through explicitly:

do {
  do {
    try f() // MyTypedError
  } catch .error1 { 
    print("error 1")
  } catch .error2 {
    print("error 2")
  } catch { rethrows }
  // Compiler will not warn about unimplemented .error3 because of rethrows
} catch {
  print("catching .error3 as any Error")
}

Maybe a shorthand way:

do {
  do {
    try f() // MyTypedError
  } catch .error1 { 
    print("error 1")
  } catch .error2 {
    print("error 2")
  } rethrows
  // Compiler will not warn about unimplemented .error3 because of rethrows
} catch {
  print("catching .error3 as any Error")
}

Otherwise, could always do this to get compiler warnings as proposed. It just gets more pyramid of doomish, but Swift often gets that way as-is so I always use two-space tabs...

do {
  do {
    try f() // MyTypedError
  } catch {
    switch error { 
    case .error1: print("error 1")
    case .error2: print("error 2")
    }
    // Compiler will warn about unimplemented .error3
  }
} catch {
  print("catching .error3 as any Error")
}

EDIT: Made some changes as I'm starting to come around to why it is pitched the way it is.

1 Like

I would love to fix this, but can we do so with a different forum thread and proposal?

Doug

8 Likes

This is amazing. Everything I ever wanted. 1000% YES! :partying_face:

I see some hate for the throws(ErrorType) syntax; I like it. It's clear, and the obvious extension to the syntax we have.

I see a lot of people talking about error unions in the thread. Personally, I don't need, want, or see the use of this feature. One single concrete error type, as per the proposal, please and thank you! (if you want an error union, you can define an enumerated union type yourself, and still be both simpler and probably more expressive than an implicit union type)

4 Likes

Although I don't think it needs to be in this pitch, it would be nice to provide an error type hint for documentation purposes and more limited type inference in these cases.

Maybe func foo() throws(FooError | any Error) {} or func foo() throws(Maybe<FooError>) {} that is just treated as any Error at the ABI level? A little extra verbosity isn't a big deal for public APIs. Union types feel most natural to me for anything that can be unboxed. Maybe would need some implicit conversion similar to Optional.

I think typed errors are compelling enough I'd want to wrap other errors inside of it unless they were extremely rare and unlikely to be caught except by a catch-all handler.

enum MyError: Error {
  case thisFunctionFailed
  case fileSaveFailed(any Error) 
}

I see it as a parameter to the function effect. I think it is fine since effects feel a lot like attributes to me anyway. They really only differ in that unlike attributes they are part of the method signature. I think it is fine looking like a parameter tuple to an attribute. I think the larger criticism is that this would be the first effect that takes a parameter. They might be the first attribute-like feature that takes a type as an argument (without .self), however tuples and associated values will take only types so there is prior art.

Your example would be introducing implicit generic types. If it were done it should be called something other than Error since implicit names are generally overridden by real names in Swift. Probably "E", "Err", or "Throws" would be better names. This would also have implications for how these are declared in protocols. I think you really want these declared in the effects so it can be part of the ABI, so the pitch is the correct way to go.

2 Likes

Personally, I think it should be up to the vendor of the API to determine whether or not an early cancellation constitutes an error. In many cases, cancellation is not an error and emitting a 'nil' sentinel value is perfectly appropriate. I'd be disappointed to see the loss of iterating an asynchronous sequence outside of a do...catch block.

Also, I wonder if this brings an opportunity to revisit the discussion raised by @DevAndArtist around asynchronous sequences and cooperative task cancellation: there's a question mark over whether or not asynchronous sequence 'plumbing' (i.e. asynchronous sequences provided by the standard lib or Swift Async Algorithms package) should be eagerly cancelling themselves and throwing Task cancellation errors at all.

In my mind, this issue is another clear hint that they shouldn't – the cancellation of an asynchronous sequence should be made deliberately by the programmer in the context of its usage.

When you think of asynchronous sequences in this way, the problem evaporates. if an asynchronous sequence needs to somehow cancel and communicate that cancellation, the programmer simply specifies their source asynchronous sequence as throwing, and – if appropriate – creates an error type to match. The programmer specifies what they need, and in turn we trust them to handle the cancellation of an asynchronous sequence as we trust them to cancel an asynchronous Task.

The job of the plumbing provided by the standard library and Swift Async Algorithms then, is to simply and faithfully distribute those elements and failure types – but otherwise, get out of the way.

1 Like

I can buy into this, it just is something that we have to write down and that authors of AsyncSequence based APIs have to think about in the future. This goes hand-in-hand with what other have raised in this thread where this proposal should provide clear guidance to adopters when typed throws should be used and what API implications they have. AsyncSequences are often used at API boundaries and IMO they probably should be either non-throwing or throwing any Error.

I agree that it is worth revisiting, because once we adopt typed throws for AsyncSequences we cannot throw a CancellationError from throwing variants anymore which means if people want their AsyncSequence to throw such an error we have to give them a hook at the root of the asynchronous chain i.e. in AsyncStream and friends.

2 Likes

Instead of introducing new syntax, how about introducing parameter pack support to enums to not rely on throw behaviour entirely:

enum Result<Success, each Failure: Error> {
  case success(Success)
  repeat case failure(each Failure)
}

And functions can declare all the possible errors, or use generics to propagate errors from passed closure. While chaining is a challenge but can be solved by following:

func aggregate<each Success, each Failure: Error>(
    results: repeat Result<each Success, some Failure>
) -> Result<(repeat each Success), repeat each Failure> {
    //
}

here some could be introduced to allow aggregating error types of all the results.

1 Like

Case study, by coincidental timing: @tikitu ran into issues with iOS 17 (vs prior versions) because the error handling in SingleValueDecodingContainer changed without warning. And from one undocumented error case to a different undocumented one (it actually used to throw DecodingError.dataCorrupted, now it throws JSONError.numberIsNotRepresentableInSwift, but neither of those is listed in the documentation as being what it can throw).

So a good example of why stronger typing for errors is necessary. And how ABI stability is not the same as actual stability. The ABI might be unbroken - it's still throwing any Error like always - but as this example shows that doesn't mean it can't completely break the caller in any library (or OS) update. And from my reading of @tikitu's story, it seems like the breakage was significantly harder to root cause than if it'd been e.g. a return value that changed, because thrown errors can traverse arbitrarily large spans of code by "magic", making them appear far away from their actual source (and in confusing ways, if they happen to trip into a different, otherwise unrelated catch clause).

7 Likes

I don't think it is. I think it's a cautionary tale about the perils of trying to specifically pick out certain thrown error types/values for special handling. If this API were using typed throws, then it could have introduced a new DecodingError.numberIsNotRepresentable case to throw, and the problem would be the same.

Doug

9 Likes

Hey all,

I've pushed a revision of the proposal document that incorporates the main discussion points from this thread. Please see the revision history for a summary of what changed.

Doug

10 Likes

I think allowing subtypes to match requirements in conformance checking is not too difficult in itself. We already have the code in SILGen to emit function conversions in expression context, and witness and vtable thunks share much of that thunking logic. However semantically, allowing subtypes to match would immediately have two knock-on effects that would need to be addressed:

  • associated type inference right now is only set up for exact matches. Would you infer Element := Int from func next() -> Int, if the requirement's return type was Optional<Element>? Or would you ban inference completely if there's also subtype variance involved?

  • if an existing conformance now has multiple potential witnesses (perhaps you had previously overloaded next() -> Int and next() -> Int?) how do you pick the "best" one? The expression checker has a rather complex set of mechanisms for ranking solutions; conformance checking would be inventing its own take.

A similar generalization could be considered for class method override checking, with its own related issues.

6 Likes

With the current proposal this would be "every tuple of Errors conforms to Error" and I'm not sure we want to go that far.

4 Likes