I believe it should be as lazy as the sequence it wraps if you use init(unfolding:)
as we do here: https://github.com/pointfreeco/swift-concurrency-extras/blob/0b9d2b5768bdacac17f3201bdb9f570e9a9ebedc/Sources/ConcurrencyExtras/AsyncThrowingStream.swift#L6-L14
I just don't buy this argument. Rust, for example, does exactly this, without having any language features around error unions. In such situations, zip would simply be disallowed if the error types weren't identical, and Swift could entirely reasonably do the same.
This comes up every time we discuss typed throws, and as someone who desperately wants typed throws, I could not care less. I have the tools I need to massage the error types (.mapError
+ enums) already.
Is it as beautifully ergonomic as it could potentially be? No.
Does it allow me to throw together things that use typed throws with different error types, thoughtlessly? No.
Would I actually ever want to do that? I doubt it.
I just want to be able to use the ergonomics of the throw
keyword, without losing the specificity of the Result
type, in order to guarantee that a) only certain types of errors are thrown out of certain functions, and b) callers can be certain they've handled all the different cases of error that can be thrown from those functions.
This is about as succinct as anything I would ever say about this.
Although it would be nice to be able to have fully typed failures, having only these two cases available is still useful. The same holds for
AsyncSequence
. Right now we have virtually no way of defining opaque interfaces that return a type like[some|any] AsyncSequence<Element, Failure>
, and I think that's a shame. In my own work, I recently had to define a customAnyAsyncSequence<Element>
* that is assumed to always be failable, because there doesn't seem to be a way to define it as not failing. This feels like a rather awkward hole in the standard library at the moment.
I just wanted to give a +1 to this entire section. Thank you for articulating it. Though in the code snippet at the start of this thread I see this:
// FIXME: In later swift versions, AsyncSequence protocol will likely have an associated error type.
Is there an evolution proposal or thread this is referenced?
Not that I’m aware of. I’ve seen discussion around this but there isn’t a proposal.
That was basically just me copy pasting my own code. Just a reminder to fix it when there is eventually a solution for the problem.
I think of AsyncSequence like the inverse of Combine. I.e. the pipeline is centralised rather than decentralised. Porting from Combine to AsyncSequence needs a complete re-architecture and in my experience I didn't require any type erasure.
For what it is worth, Xcode 16 added “primary associated types” (PATs) for AsyncSequence
, for which the Failure
type is only available for recent OS versions. So, in Xcode 16 targeting macOS 15, iOS 18, etc., and later, you can do the following, out of the box:
func numbers() -> some AsyncSequence<Int, any Error> {…}
E.g.,
func numbers() -> some AsyncSequence<Int, any Error> {
AsyncThrowingStream<Int, any Error> { continuation in
let task = Task {
do {
for i in 0 ..< 10 {
continuation.yield(i)
try await Task.sleep(for: .seconds(1))
}
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
continuation.onTermination = { state in
if case .cancelled = state {
task.cancel()
}
}
}
}
I know this is of little solace to developers targeting early OS versions, but just a FYI for those who stumble across this discussion in the future.
numbers()
would be called within a Task
(i.e. an async context) so it is a mistake to make a new top level Task
inside of the stream because you are not at the top level. Furthermore continuations are for interfacing with non-async code, it is a mistake to use them to interface into async code. (UnsafeContinuation | Apple Developer Documentation)
You can avoid these issues if you think about async in an inverted (or inside out) way from normal code.
A few thoughts:
-
You seem to contend that
Task
should only be used to bridge from synchronous contexts to Swift concurrency. IMHO, that is not the case. We use unstructured concurrency whenever we need fine-grained control and are willing encumber ourselves with the overhead of handing cancelation and the like. As The Swift Programming Guide says about unstructured concurrency:So, yes, we favor structured concurrency where we can, but when one needs this “complete flexibility”, we use unstructured concurrency.
-
You seem to have taken exception to the use of unstructured concurrency in an
AsyncStream
. For what it is worth, this is just an adaptation of Apple’s example in theAsyncStream
documentation, except I added cancellation handling. -
This is all unrelated to the original question of type erasure of
AsyncSequence
types. And as I said, in those isolated cases where one wants/needs type erasure of asynchronous sequences, we used to have to resort to all sorts of sleight of hand, but we get it for free in macOS 15+, iOS 18+, etc., with the introduction of PATs.