[Pitch] Typed throws in the Concurrency module

Hey all,

I took a stab at implementing the AsyncSequence part of this protocol, to assess whether it's possible to introduce typed throws in a manner that is both backward compatible and achieves the composability we want, with support for any AsyncSequence<Element, Failure> and such. The implementation in the compiler and library is in this pull request, along with toolchains to play with, but the salient details are below.

tl;dr we can stage in the new Failure associated type without breaking existing code, and the unfinished/unofficial @rethrows can be removed over time.

With my implementation, AsyncSequence and AsyncIteratorProtocol both get Failure associated types and adopt primary associated types, as in the proposal. AsyncIteratorProtocol gets a new function requirement _nextElement() that is a typed-throws version of next():

protocol AsyncIteratorProtocol<Element, Failure> {
  associatedtype Element
  associatedtype Failure: Error = any Error
  mutating func next() async throws -> Element?
  mutating func _nextElement() async throws(Failure) -> Element?
}

public protocol AsyncSequence<Element, Failure> {
  associatedtype AsyncIterator: AsyncIteratorProtocol
  associatedtype Element where AsyncIterator.Element == Element
  associatedtype Failure = AsyncIterator.Failure where AsyncIterator.Failure == Failure
  func makeAsyncIterator() -> AsyncIterator
}

Because existing AsyncIteratorProtocol-conforming types only implement next(), we need to provide a default implementation of _nextElement:

extension AsyncIteratorProtocol {
  /// Default implementation of `_nextElement()` in terms of `next()`, which is
  /// required to maintain backward compatibility with existing async iterators.
  public mutating func _nextElement() async throws(Failure) -> Element? {
    do {
      return try await next()
    } catch {
      throw error as! Failure
    }
  }
}

I didn't also implement next() in terms of _nextElement(), but we'd want to do that so that new async sequences could implement just _nextElement().

Now, one of the harder problems is how to get the right Failure type for existing async sequences. If the async sequence doesn't get recompiled, it'll get the default Failure type of any Error at runtime. This is fine---either it doesn't throw anything in practice, or its clients will see the any Error instance.

When the async sequence does get recompiled, we want to pick an appropriate Failure type even when there is no explicitly-specified one. I ended up using the following inference logic based on the next() implementation:

  • If next() throws nothing, Failure is inferred to Never.
  • If next() throws, Failure is inferred to any Error.
  • If next() rethrows, Failure is inferred to T.Failure, where T is the first type parameter with a conformance to either AsyncSequence or AsyncIteratorProtocol. If there are multiple such requirements, take the errorUnion of them all.

The async for..in loop switches from using next() to using _nextElement(), so iteration over an async sequence throws its Failure type. This subsumes the specialized behavior for @rethrows (if Failure is Never, you don't need the try because nothing is thrown), and gives us typed-throws behavior for iteration.

@rethrows protocols had another bit of special behavior, which is that conformance requirements to @rethrows protocols can be considered as sources of errors for rethrowing. So, you can currently write a rethrows function like this:

extension AsyncSequence {
  func contains(_ predicate: (Element) async throws -> Bool) rethrows -> Bool { ... }
}

and this function can throw if either the AsyncSequence throws (i.e., it's Failure type is not Never) or if the predicate throws. @pyrtsa noted this issue. I've partially addressed the problem by introducing a specific rule that allows requirements on AsyncSequence and AsyncIteratorProtocol to be involved in rethrows checking, so existing code that uses rethrows in this manner with async sequences will continue to work.

However, that doesn't address the fact that we can't write a proper typed throws signature for contains. As @pyrtsa noted, we could elevate errorUnion to an actual type in the type system, so we could write, e.g.,:

extension AsyncSequence {
  func contains<E: Error>(_ predicate: (Element) async throws(E) -> Bool) throws(errorUnion(Failure,E))  -> Bool { ... }
}

that's effectively what I've turned rethrows into, implicitly. We'd need to do something like this to fully replace the current rethrows behavior.

Doug

21 Likes