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.
- AsyncThrowingCompactMapSequence<Base: AsyncSequence, ElementOfResult>
- AsyncThrowingDropWhileSequence<Base: AsyncSequence>
- AsyncThrowingFilterSequence<Base: AsyncSequence>
- AsyncThrowingFlatMapSequence<Base: AsyncSequence, SegmentOfResult: AsyncSequence>
- AsyncThrowingMapSequence<Base: AsyncSequence, Transformed>
- AsyncThrowingPrefixWhileSequence<Base: AsyncSequence>
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):
- Typed throws
- Typed throw functions
- [Discussion] Analysis of the design of typed throws
- [Pitch] Typed throws
- typed throws
- [Proposal] Typed throws
- Proposal: Typed throws
- Extended idea on typed-throws: automatic determination
- Proposal: Allow Type Annotations on Throws
- Type-annotated throws
Some fairly recent topics:
- [Pitch] Rethrowing protocol conformances
- [Amendment] SE-0296: Allow overloads that differ only in async
- SE-0314 (Second review): AsyncStream and AsyncThrowingStream
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.