Contents

SE-0421: Generalize effect polymorphism for `AsyncSequence` and `AsyncIteratorProtocol`

* Proposal: [SE-0421](0421-generalize-async-sequence.md) * Authors: [Doug Gregor](https://github.com/douggregor), [Holly Borla](https://github.com/hborla) * Review Manager: [Freddy Kellison-Linn](https://github.com/Jumhyn) * Status: **Implemented (Swift 6.0)** * Review: ([pitch](https://forums.swift.org/t/pitch-generalize-asyncsequence-and-asynciteratorprotocol/69283))([review](https://forums.swift.org/t/se-0421-generalize-effect-polymorphism-for-asyncsequence-and-asynciteratorprotocol/69662)) ([acceptance](https://forums.swift.org/t/accepted-se-0421-generalize-effect-polymorphism-for-asyncsequence-and-asynciteratorprotocol/69973))

Introduction

This proposal generalizes AsyncSequence in two ways:

  1. Proper throws polymorphism is accomplished with adoption of typed throws.
  2. A new overload of the next requirement on AsyncIteratorProtocol includes an isolated parameter to abstract over actor isolation.

Table of Contents

+ Adopting typed throws - Error type inference from for try await loops + Adopting primary associated types + Adopting isolated parameters + Default implementations of next() and next(isolation:) + Associated type inference for AsyncIteratorProtocol conformances

+ Add a default argument to next(isolation:)

+ Avoiding an existential parameter in next(isolation:)

Motivation

AsyncSequence and AsyncIteratorProtocol were intended to be polymorphic over the throws effect and actor isolation. However, the current API design has serious limitations that impact expressivity in generic code, Sendable checking, and runtime performance.

Some AsyncSequences can throw during iteration, and others never throw. To enable callers to only require try when the given sequence can throw, AsyncSequence and AsyncIteratorProtocol used an experimental feature to try to capture the throwing behavior of a protocol. However, this approach was insufficiently general, which has also prevented AsyncSequence from adopting primary associated types. Primary associated types on AsyncSequence would enable hiding concrete implementation details behind constrained opaque or existential types, such as in transformation APIs on AsyncSequence:

extension AsyncSequence {
  // 'AsyncThrowingMapSequence' is an implementation detail hidden from callers.
  public func map<Transformed>(
    _ transform: @Sendable @escaping (Element) async throws -> Transformed
  ) -> some AsyncSequence<Transformed, any Error> { ... }
}

Additionally, AsyncSequence types are designed to work with Sendable and non-Sendable element types, but it's currently impossible to use an AsyncSequence with non-Sendable elements in an actor-isolated context:

class NotSendable { ... }

@MainActor
func iterate(over stream: AsyncStream<NotSendable>) {
  for await element in stream { // warning: non-sendable type 'NotSendable?' returned by implicitly asynchronous call to nonisolated function cannot cross actor boundary

  }
}

Because AsyncIteratorProtocol.next() is nonisolated async, it always runs on the generic executor, so calling it from an actor-isolated context crosses an isolation boundary. If the result is non-Sendable, the call is invalid under strict concurrency checking.

More fundamentally, calls to AsyncIteratorProtocol.next() from an actor-isolated context are nearly always invalid in practice today. Most concrete AsyncIteratorProtocol types are not Sendable; concurrent iteration using AsyncIteratorProtocol is a programmer error, and the iterator is intended to be used/mutated from the isolation domain that formed it. However, when an iterator is formed in an actor-isolated context and next() is called, the non-Sendable iterator is passed across isolation boundaries, resulting in a diagnostic under strict concurrency checking.

Finally, next() always running on the generic executor is the source of unnecessary hops between an actor and the generic executor.

Proposed solution

This proposal introduces a new associated type Failure to AsyncSequence and AsyncIteratorProtocol, adopts both Element and Failure as primary associated types, adds a new protocol requirement to AsyncIteratorProtocol that generalizes the existing next() requirement by throwing the Failure type, and adds an isolated parameter to the new requirement to abstract over actor isolation:

@available(SwiftStdlib 5.1, *)
protocol AsyncIteratorProtocol<Element, Failure> {
  associatedtype Element

  mutating func next() async throws -> Element?

  @available(SwiftStdlib 6.0, *)
  associatedtype Failure: Error = any Error

  @available(SwiftStdlib 6.0, *)
  mutating func next(isolation actor: isolated (any Actor)?) async throws(Failure) -> Element?
}

@available(SwiftStdlib 5.1, *)
public protocol AsyncSequence<Element, Failure> {
  associatedtype AsyncIterator: AsyncIteratorProtocol
  associatedtype Element where AsyncIterator.Element == Element

  @available(SwiftStdlib 6.0, *)
  associatedtype Failure = AsyncIterator.Failure where AsyncIterator.Failure == Failure

  func makeAsyncIterator() -> AsyncIterator
}

The new next(isolation:) has a default implementation so that conformances will continue to behave as they do today. Code generation for for-in loops will switch over to calling next(isolation:) instead of next() when the context has appropriate availability.

Detailed design

### Adopting typed throws

Concrete `AsyncSequence` and `AsyncIteratorProtocol` types determine whether calling `next()` can `throw`. This can be described in each protocol with a `Failure` associated type that is thrown by the `AsyncIteratorProtocol.next(isolation:)` requirement. Describing the thrown error with an associated type allows conformances to fulfill the requirement with a type parameter, which means that libraries do not need to expose separate throwing and non-throwing concrete types that otherwise have the same async iteration functionality.

#### Error type inference from `for try await` loops

The `Failure` associated type is only accessible at runtime in the Swift 6.0 standard library; code running against older standard library versions does not include the `Failure` requirement in the witness tables for `AsyncSequence` and `AsyncIteratorProtocol` conformances. This impacts error type inference from `for try await` loops.

When the thrown error type of an `AsyncIteratorProtocol` is available, either through the associated type witness (because the context has appropriate availability) or because the iterator type is concrete, iteration over an async sequence throws its `Failure` type:

```swift
struct MyAsyncIterator: AsyncIteratorProtocol {
  typealias Failure = MyError
  ...
}

func iterate<S: AsyncSequence>(over s: S) where S.AsyncIterator == MyAsyncIterator {
  let closure = {
    for try await element in s {
      print(element)
    }
  }
}
```

In the above code, the type of `closure` is `() async throws(MyError) -> Void`.

When the thrown error type of an `AsyncIteratorProtocol` is not available, iteration over an async sequence throws `any Error`:

```swift
@available(SwiftStdlib 5.1, *)
func iterate(over s: some AsyncSequence) {
  let closure = {
    for try await element in s {
      print(element)
    }
  }
}
```

In the above code, the type of `closure` is `() async throws(any Error) -> Void`.

When the `Failure` type of the given async sequence is constrained to `Never`, `try` is not required in the `for-in` loop:

```swift
struct MyAsyncIterator: AsyncIteratorProtocol {
  typealias Failure = Never
  ...
}

func iterate<S: AsyncSequence>(over s: S) where S.AsyncIterator == MyAsyncIterator {
  let closure = {
    for await element in s {
      print(element)
    }
  }
}
```

In the above code, the type of `closure` is `() async -> Void`.

### Adopting primary associated types

The `Element` and `Failure` associated types are promoted to primary associated types. This enables using constrained existential and opaque `AsyncSequence` and `AsyncIteratorProtocol` types, e.g. `some AsyncSequence<Element, Never>` or `any AsyncSequence<Element, any Error>`.

### Adopting isolated parameters

The `next(isolation:)` requirement abstracts over actor isolation using [isolated parameters](/proposals/0313-actor-isolation-control.md). For callers to `next(isolation:)` that pass an iterator value that cannot be transferred across isolation boundaries under [SE-0414: Region based isolation](/proposals/0414-region-based-isolation.md), the call is only valid if it does not cross an isolation boundary. Explicit callers can pass in a value of `#isolation` to use the isolation of the caller, or `nil` to evaluate `next(isolation:)` on the generic executor.

Desugared async `for-in` loops will call `AsyncIteratorProtocol.next(isolation:)` instead of `next()` when the context has appropriate availability, and pass in an isolated argument value of `#isolation` of type `(any Actor)?`. The `#isolation` macro always expands to the isolation of the caller so that the call does not cross an isolation boundary.

### Default implementations of `next()` and `next(isolation:)`

Because existing `AsyncIteratorProtocol`-conforming types only implement `next()`, the standard library provides a default implementation of `next(isolation:)`:

```swift
extension AsyncIteratorProtocol {
  /// Default implementation of `next(isolation:)` in terms of `next()`, which is
  /// required to maintain backward compatibility with existing async iterators.
  @available(SwiftStdlib 6.0, *)
  @available(*, deprecated, message: "Provide an implementation of 'next(isolation:)'")
  public mutating func next(isolation actor: isolated (any Actor)?) async throws(Failure) -> Element? {
    nonisolated(unsafe) var unsafeIterator = self
    do {
      let element = try await unsafeIterator.next()
      self = unsafeIterator
      return element
    } catch {
      throw error as! Failure
    }
  }
}
```

Note that the default implementation of `next(isolation:)` necessarily violates `Sendable` checking in order to pass `self` from a possibly-isolated context to a `nonisolated` one. Though this is generally unsafe, this is how calls to `next()` behave today, so existing conformances will maintain the behavior they already have. Implementing `next(isolation:)` directly will eliminate the unsafety.

To enable conformances of `AsyncIteratorProtocol` to only implement `next(isolation:)`, a default implementation is also provided for `next()`:

```swift
extension AsyncIteratorProtocol {
  @available(SwiftStdlib 6.0, *)
  public mutating func next() async throws -> Element? {
    // Callers to `next()` will always run `next(isolation:)` on the generic executor.
    try await next(isolation: nil)
  }
}
```

Both function requirements of `AsyncIteratorProtocol` have default implementations that are written in terms of each other, meaning that it is a programmer error to implement neither of them. Types that are available prior to the Swift 6.0 standard library must provide an implementation of `next()`, because the default implementation is only available with the Swift 6.0 standard library.

To avoid silently allowing conformances that implement neither requirement, and to facilitate the transition of conformances from `next()` to `next(isolation:)`, we add a new availability rule where the witness checker diagnoses a protocol conformance that uses an deprecated, obsoleted, or unavailable default witness implementation. Deprecated implementations will produce a warning, while obsoleted and unavailable implementations will produce an error.

Because the default implementation of `next(isolation:)` is deprecated, conformances that do not provide a direct implementation will produce a warning. This is desirable because the default implementation of `next(isolation:)` violates `Sendable` checking, so while it's necessary for source compatibility, it's important to aggressively suggest that conforming types implement the new method.

### Associated type inference for `AsyncIteratorProtocol` conformances

When an `AsyncIteratorProtocol`-conforming type provides a `next(isolation:)` function, the `Failure` type is inferred based on whether (and what) `next(isolation:)` throws using the rules described in [SE-0413](/proposals/0413-typed-throws.md).

If the `AsyncIteratorProtocol`-conforming type uses the default implementation of `next(isolation:)`, then the `Failure` associated type is inferred from the `next` function instead. Whatever type is thrown from the `next` function (including `Never` if it is non-throwing) is inferred as the `Failure` type.

Source compatibility

The new requirements to AsyncSequence and AsyncIteratorProtocol are additive, with default implementations and Failure associated type inference heuristics that ensure that existing types that conform to these protocols will continue to work.

The experimental "rethrowing conformances" feature used by AsyncSequence and AsyncIteratorProtocol presents some challenges for source compatibility. Namely, one can declare a rethrows function that considers conformance to these rethrowing protocols as sources of errors for rethrowing. For example, the following rethrows function is currently valid:

extension AsyncSequence {
  func contains(_ value: Element) rethrows -> Bool where Element: Hashable { ... }
}

With the removal of the experimental "rethrowing conformances" feature, this function becomes ill-formed because there is no closure argument that can throw. To preserve source compatibility for such functions, this proposal introduces a specific rule that allows requirements on AsyncSequence and AsyncIteratorProtocol to be involved in rethrows checking: a rethrows function is considered to be able to throw T.Failure for every T: AsyncSequence or T: AsyncIteratorProtocol conformance requirement. In the case of this contains operation, that means it can throw Self.Failure. The rule permitting the definition of these rethrows functions will only be permitted prior to Swift 6.

ABI compatibility

This proposal is purely an extension of the ABI of the standard library and does not change any existing features. Note that the addition of a new next(isolation:) requirement, rather than modifying the existing next() requirement, is necessary to maintain ABI compatibility, because changing next() to abstract over actor isolation requires passing the actor as a parameter in order to hop back to that actor after any async calls in the implementation. The typed throws ABI is also different from the rethrows ABI, so the adoption of typed throws alone necessitates a new requirement.

Implications on adoption

The associated Failure types of AsyncSequence and AsyncIteratorProtocol are only available at runtime with the Swift 6.0 standard library, because code that runs against prior standard library versions does not have a witness table entry for Failure. Code that needs to access the Failure type through the associated type, e.g. to dynamic cast to it or constrain it in a generic signature, must be availability constrained. For this reason, the default implementations of next() and next(isolation:) have the same availability as the Swift 6.0 standard library.

This means that concrete AsyncIteratorProtocol conformances cannot switch over to implementing next(isolation:) only (without providing an implementation of next()) if they are available earlier than the Swift 6.0 standard library.

Similarly, primary associated types of AsyncSequence and AsyncIteratorProtocol must be gated behind Swift 6.0 availability.

Once the concrete AsyncIteratorProtocol types in the standard library, such as Async{Throwing}Stream.Iterator, implement next(isolation:) directly, code that iterates over those concrete AsyncSequence types in an actor-isolated context may exhibit fewer hops to the generic executor at runtime.

Future directions

Add a default argument to next(isolation:)

Most calls to next(isolation:) will pass the isolation of the enclosing context. We could consider lifting the restriction that protocol requirements cannot have default arguments, and adding a default argument value of #isolated as described in the pitch for actor isolation inheritance.

Alternatives considered

Avoiding an existential parameter in next(isolation:)

The isolated parameter to next(isolation:) has existential type (any Actor)? because a nil value is used to represent nonisolated. There is no concrete Actor type that describes a nonisolated context, which necessitates using (any Actor)? instead of some Actor or (some Actor)?. Potential alternatives to this are:

  1. Represent nonisolated with some other value than nil, or a specific declaration in the standard library that has a concrete optional actor type to enable (some Actor)?. Any solution in this category requires the compiler to have special knowledge of the value that represents nonisolated for actor isolation checking of the call.
  2. Introduce a separate entrypoint for next(isolation:) that is always nonisolated. This defeats the purpose of having a single implementation of next(isolation:) that abstracts over actor isolation.

Note that the use of an existential type (any Actor)? means that embedded Swift would need to support class existentials in order to use next(isolation:).

Acknowledgments

Thank you to Franz Busch and Konrad Malawski for starting the discussions about typed throws and primary associated type adoption for AsyncSequence and AsyncIteratorProtocol in the Typed throws in the Concurrency module pitch. Thank you to John McCall for specifying the rules for generalized isolated parameters in the pitch for inheriting the caller's actor isolation.