Request to amend `AsyncSequence`

Before I start explaining the reasoning behind my request, I would like to ask the Core Team directly:

  • Is the typed throws feature inevitable in the future of the Swift language?

To begin with, I'd like to thank everyone who's hardly working and pushing the limits of the Swift language. I as a daily language user highly appreciate your work.

AsyncSequence is a protocol which aims to enable features similar to reactive programming but in an async style. I would personally say that AsyncSequence is fairly similar to Combine.Publisher. A sequence is turned into emitting values asynchronously over time while there may be a Failure during the emission.

However due to the lack of "typed throws" the design of AsyncIteratorProtocol erases the error type to Error.

public protocol AsyncIteratorProtocol {
  associatedtype Element

  mutating func next() async throws -> Element?
}

I personally view this a huge mistake. This is especially very confusing to me as we just currently were able to change the design for AsyncThrowingStream to include a Failure: Error generic type parameter.

While we approved this design change, I don't understand why all other async sequences still lack of the Failure: Error generic type parameter.

The lack of typed throws also pushed us to introduce the Result<Value, Failure: Error> type. That's why I'm wondering why we cannot make use of Result while we don't have any first class support for typed throws?!

I propose to amend the AsyncIteratorProtocol to the following signature:

public protocol AsyncIteratorProtocol {
  associatedtype Element
  associatedtype Failure: Error

  mutating func next() async -> Result<Element, Failure>?

  // in the future the compiler can treat this method equally as if it was
  // `mutating func next() async throws Failure -> Element?`
}

And also extend the compilers type promotion for Result in certain situations, just like we already do with Optional:

let array: [Result<Int, Error>] = ...
for case .success(let value) in array { ... }

for try value in array { ... } // NEW

// the latter can be sugar for
var iterator = array.makeIterator()
while let value = try iterator.next()?.get() {
  ...
}

While not being async, a typed throwing Iterator protocol was previously mentioned by @Joe_Groff in this older post:

The non-throwing-ness (if that's a word) can just equal throws Never, which can be omitted entirely.

The Combine framework already provided an answer to how the error type should be handled between operators. The answer is as simple to require the error type to be equivalent. If the user needs a different error type the framework provides ways through special operators to map the error type.

Based on that idea I took one of the previously mentioned async sequences and added the missing Failure: Error generic type parameter.

// Only used because we needed a change made in `_AsyncIteratorProtocol`.
public protocol _AsyncSequence {
  associatedtype AsyncIterator: _AsyncIteratorProtocol
  associatedtype Element where Self.Element == Self.AsyncIterator.Element

  func makeAsyncIterator() -> Self.AsyncIterator
}

public protocol _AsyncIteratorProtocol {
  associatedtype Element
  associatedtype Failure: Error // NEW

  // NEW return type
  mutating func next() async -> Result<Self.Element, Failure>?
}

extension _AsyncSequence {
  @inlinable
  public __consuming func map<Transformed>(
    _ transform: @escaping (Element) async throws -> Transformed
  ) -> AsyncThrowingMapSequence<Self, Transformed, Error> {

    AsyncThrowingMapSequence(self) { element in
      // wrapping the value and failure into the `Result`
      do {
        return await .success(try transform(element))
      } catch {
        return .failure(error)
      }
    }
  }
}

public struct AsyncThrowingMapSequence<
  Base: _AsyncSequence,
  Transformed,
  Failure
> where Base.AsyncIterator.Failure == Failure {
  // Make sure we align the failure types. This is also true in `Combine`
  // and we would need a separate sequence to change / map the `Failure` type.
  @usableFromInline
  let base: Base

  @usableFromInline
  // Use a result type
  let transform: (Base.Element) async -> Result<Transformed, Failure>

  @usableFromInline
  init(
    _ base: Base,
    transform: @escaping (Base.Element) async -> Result<Transformed, Failure>
  ) {
    self.base = base
    self.transform = transform
  }
}

extension AsyncThrowingMapSequence: _AsyncSequence {

  public typealias Element = Transformed
  /// The type of iterator that produces elements of the sequence.
  public typealias AsyncIterator = Iterator

  /// The iterator that produces elements of the map sequence.
  public struct Iterator: _AsyncIteratorProtocol {
    @usableFromInline
    var baseIterator: Base.AsyncIterator

    @usableFromInline
    let transform: (Base.Element) async -> Result<Transformed, Failure>

    @usableFromInline
    var finished = false

    @usableFromInline
    init(
      _ baseIterator: Base.AsyncIterator,
      transform: @escaping (Base.Element) async -> Result<Transformed, Failure>
    ) {
      self.baseIterator = baseIterator
      self.transform = transform
    }

    @inlinable
    public mutating func next() async -> Result<Transformed, Failure>? {
      guard
        !finished,
        let result = await baseIterator.next()
      else {
        return nil
      }
      switch result {
      case .success(let element):
        return await transform(element)
      case .failure(let error):
        finished = true
        return .failure(error)
      }
    }
  }

  @inlinable
  public __consuming func makeAsyncIterator() -> Iterator {
    return Iterator(base.makeAsyncIterator(), transform: transform)
  }
}

If these types were future proof then there wouldn't be any need to introduce another set of types with explicit Failure generic parameter, which wouldn't be back deployable unless marked with the @_alwaysEmitIntoClient keyword (presuming there is no need for any new runtime features) in the future. I think the issue with the async features not being backwards compatible was clearly communicated in this thread. Therefore I opened this new thread to talk about the problems we will likely run into if we overlook this issue now.

In fact with the above implementation, in the future with typed throws the standard library can just introduce a back-deployable overload to support the explicit generic error type parameter.

extension _AsyncSequence {
  @_alwaysEmitIntoClient
  @inlinable
  public __consuming func map<Transformed, Failure: Error>(
    _ transform: @escaping (Element) async throws Failure -> Transformed
  ) -> AsyncThrowingMapSequence<Self, Transformed, Failure> { ... }
}

Having an explicit Failure type allows us the introduction of a single type for each operation while we can introduce a simple type alias for the non-throwing counter part:

public typealias AsyncStream<Element> = AsyncThrowingStream<Element, Never>

Related topics (or typed throws mentions):


Some fairly recent topics:

The collection of all these threads only demonstrates the high demand for "typed throws", which shouldn't be avoided when designing features such as AsyncSequence.


I hope the issue I want to discuss in this thread is somewhat understandable. I was trying to sum up my thoughts as good as possible. I'm a daily Swift user and I will be one of the new APIs consumers, that's why I wish those to be future proof, not only for myself, but for the entire community in-general. That's the whole reason, why I think erasing the error type in AsyncIteratorProtocol will be a huge mistake that we the consumers will have to deal with in the long run. While we cannot introduce typed throws that fast in the current release cycle. I think using Result with a bit of compiler sugur in the meantime would be very much reasonable.

25 Likes

I've been thinking the same thing recently. We keep adding generic parameters in case we ever add typed throws; but it's been discussed at length, several times, and AFAICT there has never been a clear consensus that it's worth it.

Generic parameters aren't free; they can come with a significant cost to code-size. It would be good to settle this so we don't keep "future-proofing".

6 Likes

Pushing this thread back to the top!

I completely agree, I also think erasing the error type would be a huge mistake. The error handling system in Combine is one of its biggest strengths, and would make the AsyncSequence system even better. Typed throws seems to be on the roadmap, so this seems like a good future-proofing move to say the least.

3 Likes

I am personally still a huge fan of typed throws, and consider it a missing feature. I don't think it should be used pervasively in high level APIs like Swift UI, but it is extremely important for low level APIs and systems code where allocation isn't acceptable. Recall that func foo() throws must implicitly allocate when the thrown type is larger than the inline existential box, but func foo() throws YourType would not need to do this. Furthermore, many low-level APIs have "closed" error types and want to be handled exhaustively with pattern matching etc.

34 Likes

Initially I had the same feeling, but looking at the concurrency features (the pervasiveness of the await/async and ‘throws’ keyword, the spotlight on ‘straight line’ coding in the related WWDC sessions, etc.) it feels like Swift is going in a different direction philosophically – specifically one where ‘straight line coding’ is preferred.

If that is indeed the case, I’m more in favour of introducing ’typed throws’.

I say that having already spent some time investigating the rabbit hole that is ‘typed throws’ where there seemed to be concern that people will abuse the feature and use it in places where perhaps it isn’t necessary.

Even though I think that concern is justified (those discussions made me realise my own approach to Error was all wrong) Pandora’s box was already opened with the introduction of the Result type.

People are typing their Failure types where they probably don’t need to.

Not providing a typed throws just means people will use a Result type instead as they will think clearly it has more power.

If we don’t want people to ‘abuse’ the feature we need to communicate how Error types should better be utilised, put out some best practice type materials on what kind of situations a typed throw is useful, and improve the utilities for Errors so they’re up to par. (I’d love a WrappingError protocol with an underlyingError: Error? property for example.)

2 Likes

I’d be interested in reading these arguments that changed your perspective. Care to share a few?

2 Likes

I would very much want to know from the Combine devs the reasoning behind the design choice of the framework having an explicit Failure type everywhere unlike for example RxSwift which doesn‘t have it. Also I‘m keen to read about the advantages, disadvantages and goals of that design and more importantly why this shouldn‘t apply to AsyncSequence.

Moving from Combine into the async style sequences without that functionality with the door closed in the future, feels like a huge downgrade.


I propose another option for the core team to consider. The debacle here isn‘t light weight and we shouldn‘t wave those types through into the stdlib just like that.

That said, I propose to move AsyncSequence and all the sequence types build from it, into a Swift Package just like swift-collections and incubate its API a little longer until we decided on the future direction of typed throws or found a better solution in the short term. This would also provide us with more time to potentially add many more missing sequence types to the package and test them before they land in the stdlib.

Sure those types were already presented at the WWDC, but so were the other packages. I think it‘s the best solution in the current time constrained situation to not rush and finalize the design and let it evolve before it‘s final inclusion into the stdlib.

cc @tkremenek

10 Likes

That sounds like a good compromise, but I'd be curious to see how AyncSequence's compiler dependency and reliance on C++ code to implement AyncStream's multi-resume locking interface would affect that.

I still agree that the error shouldn't be erased – I would love to hear @Philippe_Hausler's reasoning for this initial error-erasing implementation though, since he took part in the creation of Combine, on which we are basing much of our own reasoning on.

2 Likes

Just one note. Looking at Publishers set of Publisher types, I just want all those types as AsyncSequence‘s (if possible). This would allow one to move from reactive programming purely into async world.

2 Likes

Agreed – combining (no pun intended) the worlds of async/await and Reactive Streams could be extremely powerful if done right. Of course, we shouldn't implement Reactive Streams for the sake of implementing Reactive Streams, it should be done in a way which takes advantage of async/await and Swift (such as conserving error types throughout the operators/stages/elements :wink:).

3 Likes

I think separating AsyncSequence into its own package is very reasonable considering the tight time constraints and the uncertainty regarding typed errors.

As for the actual solution, I understand people’s worries regarding typed errors. The most commonly cited is the potential for implementation-detail error types to be unintentionally and — more importantly — unnecessarily leaked into public API. As a result, libraries will have a harder time evolving and their clients may be confused and overwhelmed.

Of course, Swift will retain the default, untyped ‘throws’ declarations for source comparability, undermining that concern.

My point, though, isn’t to argue for the addition of types errors. I would just like to see a more sustainable error propagation model.

The existing Rethrowing Protocols Conformances feature seems messy and complex. Don’t get me wrong, it is a great addition; nevertheless, a temporary one. The feature doesn’t allow library developers to explicitly and precisely state what errors are rethrown. Consequently, implementing a catch(_:) method — with Combine-like semantics — that gracefully handles error propagation without excessive boilerplate is not feasible today.

To sum up, I think that even if we settle on not exposing typed errors, we should at least advance @rethrows to match generics’ clear semantics, power and reliability.

Until then, separating AsyncSequence into their own, distinct package seems very sensible.


I know most of this post points out the shortcomings of the current design. In spite of that, I’m very grateful for the work that the team and the community have put into Swift concurrency and truly appreciate the simplicity and versatility of the AsyncSequence API.

4 Likes

Nobody has solved the problem of non-composable enums yet, that Result.Failure and every other enum case has. (Enum cases do not stack together into groups; the fastest solution is manual collection into a parent enum that packs the originals into associated values. This pattern has not caught on.)

Take care of the general case by sneaking in some use case that provokes less argument, and typed throws will be an easy step up.

TL; DR rethrowing protocol conformances look like they give us a way out of the problems described here without going to a full typed-throws model.

Not speaking for the core team here, just myself. It seems likely, but the design of typed throws is not trivial and we potentially have to deal with more fundamental issues before the feature makes sense. For example, typed throws makes it far, far too easy to overconstrain APIs in a manner that limits their evolution. Can we do something better? Also, typed throws immediately runs into the need/desire for structural union types, which themselves require a lot of design.

However, I think this question is somewhat beside the point. With Result, and now with the concurrency APIs, we're embracing the notion of a parameter to describe failure. Even if we never get typed throws, having the ability to generalize over the Failure == Never and Failure == Error cases is useful. So, I disagree with part of your comment here:

I agree that this could be a mistake that prevents future evolution. However, I don't necessarily agree that you need typed throws to solve the problem. Rethrowing protocol conformances capture the notion of throwing-ness of a particular conformance. Rethrowing protocol conformances are how you can

for await x in someAsyncSequence { ... }

when iteration through someAsyncSequence doesn't actually throw, and you add the try keyword in there when iteration through that sequence can throw.

I think the actual problem here is that, while the compiler keeps track of whether a conformance to a rethrows protocol is throwing or not, it doesn't expose that information in a manner that's useful for building on top of it. For example, let's imagine that AsyncIteratorProtocol looked like this:

@rethrows public protocol AsyncIteratorProtocol {
  associatedtype Element
  associatedtype Failure: Error = /*Never if the conforming type's next() is non-throwing, Error otherwise */

  mutating func next() async throws -> Element?
}

So, there's a little magic there in the way the type is inferred, because here's what we'd end up with:

struct MyNonThrowingIterator: AsyncIteratorProtocol {
  // infer Element = Int, Failure = Never
  mutating func next() async -> Int? { ... }
}

struct MyThrowingIterator: AsyncIteratorProtocol {
  // infer String = Int, Failure = Error
  mutating func next() async throws -> String? { ... }
}

I think that handles all of the cases you describe above, and it's built mostly on logic that already exists in the compiler. If we were to get typed throws in the future, AsyncIteratorProtocol could evolve to something like this:

@rethrows public protocol AsyncIteratorProtocol {
  associatedtype Element
  associatedtype Failure: Error

  mutating func next() async throws(Failure) -> Element?
}

I'm fairly certain we can make that evolution both source- and ABI-compatible, because generic clients have to deal with an arbitrary Failure anyway, and the rule for inferring an associated type from a typed throws will have to infer Never for non-throwing witnesses and Error for witnesses that use throws.

This is a fairly drastic measure. Again, I'm not speaking for the whole Core Team here, but I think we have better options than to rip out an important part of the concurrency model. For reference, an associated type with a default can be added to a protocol without breaking ABI. So even if we had today's definition of AsyncIteratorProtocol, we could extend it later [*]:

@rethrows public protocol AsyncIteratorProtocol {
  associatedtype Element

  @available(Swift 5.6 or whatever)
  associatedtype Failure: Error = /*Never if the conforming type's next() is non-throwing, Error otherwise */

  mutating func next() async throws -> Element?
}

I think what's happening here is that you're comparing an implemented and therefore fully-understood design for rethrowing protocol conformances to an idealized view of typed throws that has yet to meet the reality of complete specification. For typed throws to solve the problems described here you need similar machinery to that of rethrowing protocol conformances, gathering up all of the potential throwing witnesses (and the types they throw!). However, instead of a simple yes/no answer, you have to consider how to combine multiple different results from different throwing witnesses, and all of the tricky semantic questions around typed throws crop up again.

I've very glad that @DevAndArtist brought this up. It's important, and I think we have reasonable solutions within reach.

Doug

[*] There is one bit of metadata we'll need to record for rethrowing protocol conformances to make the defaulting work properly. It's not a big deal.

16 Likes

To provide context: To date, my assumptions Around Error handling in Swift had been that 'throws' was a less capable imperative counterpart to the more functional style of error handling. Types such as Result, or Combine's Publisher conforming types – which are generic over their Error conforming Failure parameters – were more powerful and therefore better.

Also, as throws wasn't suitable across async boundaries, I rarely reached for it in my own APIs, preferring to use Result. If I had a dependency which included throwing APIs – for any thrown Error that I was unable to recover – I would wrap the underlying Error within the base Error type for whatever module I was working within and propagate it up.

But after reviewing the concurrency work and the supporting learning materials created for WWDC, firstly it was clear that the async issue with throws has been solved, but also that there seemed to be an emphasis on 'straight line coding' that preferred to break out of the monadic style of functional programming for typical use-cases.

At least, that's the message I received.

The one thing I didn't get though was how would I would now constrain my thrown Error types. Usually, I'd create a base Error type, perhaps conform it to LocalizedError at the application level and, for APIs that could fail, make use of it in a Result or Publisher.

That's when I came across the typed throws discussions – in my efforts to come up with a workaround. There's a lot of interesting stuff in there, but particularly this post by @John_McCall struck a chord with me:

And it's absolutely true in my case.

My search for a solution for typed throws was almost entirely dogmatic. Whilst there are a small subset of problems for which I need a typed throws, for most use cases a non-typed throwing API actually makes more sense for me.

A big reason I avoided using throws was that casting to an existential in a catch clause seemed like a code smell, but is it worse that including an associated value in your module's Error type to for each dependency's own Error type? On balance I decided 'probably not'.

The only thing that I find myself missing is context.

If there is some unknown error that occurs two modules deep, it would be good to know exactly what's occurring at the point the error is thrown – the same way the callstack generated by a fatal error provides context for a crash. That's really the only reason I can think of to embed the previous errors (assuming the error is non recoverable) – and is why I recently suggested adding an underlyingError: Error? property via a WrappingError protocol.

If that were provided, or a similar mechanism for providing context in non-fatal error situations, I think the APIs where the need for typed Errors exists would be reduced to the domains described by John in his quoted post above.

7 Likes

I had a similar initial reaction when I first saw the pitch, but I realized the logical basis for this feature is pretty simple and sound. We can probably polish the pitch to add some more details. Also, the implementation of rethrows conformances, together with 'reaync' functions, was based on a refactoring of this part of the type checker to deal with effects abstractly, and directly express effect polymorphism. The intended goal was to make it easier to add more kinds of composable effects in the future.

7 Likes

This is great news to me. A pragmatic approach to tackle composition of specific effects - without the need to base everything on specific generic types - that it's also open to "extension" in the future with different types of effects (read from environment, state mutation, I/O, non-determinism et cetera) sounds amazing and could open the possibility of mimicking algebraic effects in Swift.

1 Like

With the amendment of SE-0296 that allows for overloads that differ only in async couldn’t we now just overload Result.init(catching body: () throws -> Success) with Result.init(catching body: () async throws -> Success) and then get Result { asyncSequence.next() } to replicate the function of this amendment for those who want Result returns while leaving the more straightforward use case as the default?

1 Like

I think I understand rethrows, but the concept of reasync doesn’t make sense to me. Is there a pitch or something that explains this concept?

Not related to this thread, but I imagine typed throws being most valuable in the absence of structural union types. We already have nominal union types, and forcing them to be declared puts back pressure on people building overly specific APIs in the Java style. The reason to use typed throws is when you have a large class of API that agree on a specific designated error type (e.g. libc and errno). Lots of APIs having different intersections of possible errors is what makes typed throws non-composable and distasteful.

-Chris

8 Likes