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.