[Pitch] Map Failure

Map Failure

During the review process, add the following fields as needed:

Introduction

The mapFailure function empowers developers to elegantly transform errors within asynchronous sequences, enhancing code readability and maintainability.

extension AsyncSequence {

    public func mapFailure<MappedFailure: Error>(_ transform: @Sendable @escaping (Self.Failure) -> MappedFailure) -> some AsyncSequence<Self.Element, MappedFailure> {
        AsyncMapFailureSequence(base: self, transform: transform)
    }

}

Detailed design

The actual implementation is quite simple actually - it's just simple do-catch block and invoking the transform closure inside the catch block - so we'll focus more on implementation decisions with regard to the compiler and OS versions difference.

extension AsyncSequence {

#if compiler(>=6.0)
    /// Converts any failure into a new error.
    ///
    /// - Parameter transform: A closure that takes the failure as a parameter and returns a new error.
    /// - Returns: An asynchronous sequence that maps the error thrown into the one produced by the transform closure.
    ///
    /// Use the ``mapFailure(_:)`` operator when you need to replace one error type with another.
    @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
    public func mapFailure<MappedFailure: Error>(_ transform: @Sendable @escaping (Self.Failure) -> MappedFailure) -> some AsyncSequence<Self.Element, MappedFailure> {
        AsyncMapFailureSequence(base: self, transform: transform)
    }
#endif

    /// Converts any error into a new error.
    ///
    /// - Parameter transform: A closure that takes the error as a parameter and returns a new error.
    /// - Returns: An asynchronous sequence that maps the error thrown into the one produced by the transform closure.
    ///
    /// Use the ``mapError(_:)`` operator when you need to replace one error type with another.
    @available(macOS, deprecated: 15.0, renamed: "mapFailure")
    @available(iOS, deprecated: 18.0, renamed: "mapFailure")
    @available(watchOS, deprecated: 11.0, renamed: "mapFailure")
    @available(tvOS, deprecated: 18.0, renamed: "mapFailure")
    @available(visionOS, deprecated: 2.0, renamed: "mapFailure")
    public func mapError<MappedError: Error>(_ transform: @Sendable @escaping (any Error) -> MappedError) -> AsyncMapErrorSequence<Self, MappedError> {
        .init(base: self, transform: transform)
    }
}

The compiler check is needed to ensure the code can be built on older Xcode versions (15 and below). AsyncSequence.Failure is only available in new SDK that ships with Xcode 16 that has the 6.0 compiler, we'd get this error without the compiler check 'Failure' is not a member type of type 'Self'.

As to the naming mapFailure versus mapError, this is the trade off we have to make due to the lack of the ability to mark function as unavailable from certain OS version. The function signatures are the same, if the function names were the same, compiler will always choose the one with any Error instead of the one that has more specific error type.

mapError function returns a concrete type instead of some AsyncSequence<Self.Element, MappedError> because AsyncSequence.Failure is only available in newer OS versions, we cannot specify it in old versions. And because using an opaque type would render typed throws feature ineffective by erasing the type, thereby preventing the compiler from ensuring that the returned sequence matches our intended new type. The benefits of using typed throws for this specific case outweigh the exposure of the internal types.

#if compiler(>=6.0)
/// An asynchronous sequence that converts any failure into a new error.
@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
struct AsyncMapFailureSequence<Base: AsyncSequence, MappedFailure: Error>: AsyncSequence {

    typealias AsyncIterator = Iterator
    typealias Element = Base.Element
    typealias Failure = Base.Failure

    private let base: Base
    private let transform: @Sendable (Failure) -> MappedFailure

    init(
        base: Base,
        transform: @Sendable @escaping (Failure) -> MappedFailure
    ) {
        self.base = base
        self.transform = transform
    }

    func makeAsyncIterator() -> Iterator {
        Iterator(
            base: base.makeAsyncIterator(),
            transform: transform
        )
    }
}

@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
extension AsyncMapFailureSequence {

    /// The iterator that produces elements of the map sequence.
    struct Iterator: AsyncIteratorProtocol {

        typealias Element = Base.Element

        private var base: Base.AsyncIterator

        private let transform: @Sendable (Failure) -> MappedFailure

        init(
            base: Base.AsyncIterator,
            transform: @Sendable @escaping (Failure) -> MappedFailure
        ) {
            self.base = base
            self.transform = transform
        }

        mutating func next() async throws(MappedFailure) -> Element? {
            do {
                return try await base.next(isolation: nil)
            } catch {
                throw transform(error)
            }
        }

        mutating func next(isolation actor: isolated (any Actor)?) async throws(MappedFailure) -> Element? {
            do {
                return try await base.next(isolation: actor)
            } catch {
                throw transform(error)
            }
        }
    }
}

@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
extension AsyncMapFailureSequence: Sendable where Base: Sendable, Base.Element: Sendable {}
#endif

AsyncMapErrorSequence would have similar implementation except it uses any Error instead of the associated Failure type, and doesn't support typed throws if the compiler is less than 6.0.

Naming

mapError follows to current method naming of the Combine mapError method.

Using mapFailure since Failure defines the type that can be thrown from an AsyncSequence.

6 Likes

A couple of points of feedback:

  1. The naming of the method should probably be something along the lines of mapFailure since the type that defines the errors that can be thrown from an AsyncSequence is the Failure. I don't think the naming is that far off; but that makes the behavior un-ambiguous in my view.

  2. The return value of the function likely should be a some type. Those should handle the typed throws if written as some AsyncSequence<Element, MappedFailure>

1 Like

Make sense, thanks for the great suggesstions!

I've updated my PR to reflect your suggesstions. The function that supports old OS versions unfortunately cannot return some AsyncSequence<Self.Element, MappedError> since Failure is only available in new OS versions, we cannot specify it in old OS versions.

That requirement is the same as the generic Failure type no? It seems to me that is a pretty reasonable requirement.

Yes. But the thing is, in old OS versions, AsyncSequence doesn't even have associated Failure type defined (at least not exposed publicly), we cannot specify it.

But I have removed that function based on Franz's comment on my PR, we'll only ship mapFailure in new OS. So that won't be a problem any more. If you can re-review the PR when you have a chance.

Conceptually this looks really reasonable to me, it solves a distinct missing feature that folks will find useful, and follows the overall design goals.

Lets let this run till next week to gather additional feedback; my vote is to merge this once we have gathered the appropriate buy-in.

1 Like

I actually think mapError is a better name than mapFailure.

  • The name is consistent with existing declarations like Result.mapError and Combine.Publisher.mapError.
  • I think calling it mapFailure implies that map should actually be called mapSuccess (which would obviously be a bad name). The name mapError, on the other hand, implies that it's like the map method, but for when an error occurs, which I think is a better explanation.

I also think we should directly return AsyncMapFailureSequence rather than a some type. If we only return some AsyncSequence<Element, MappedError>, then the type system ignores the fact that AsyncMapFailureSequence is conditionally Sendable, which will make it more difficult to work with in async environments.

It might also be worth publicly exposing AsyncMapFailureSequence's base and transform properties so that people don't have to reimplement the type in order to access those properties. Access to those properties can be useful for conforming it to user-defined protocols and I think it's unlikely that this type's contents are ever going to change.

Additionally, I think it's worth considering that non-escapable types are currently being added to the language, and it might make sense to make AsyncMapFailureSequence a non-escapable type in order to avoid some restrictions of escaping closures. (I haven't been paying too much attention to those proposals, though, so I can't say for sure whether that would work or not.)

4 Likes

I would prefer mapError for consistency with Result, but I could live with either. :slight_smile:

4 Likes

Same here, I don't have a strong opinion on mapError or mapFailure, both had a good point. I'm slightly leaning towards mapError because it matches existing function naming in Combine.

PR for mapError: Introduce mapError function by clive819 · Pull Request #324 · apple/swift-async-algorithms · GitHub
PR for mapFailure: Introduce mapFailure function by clive819 · Pull Request #338 · apple/swift-async-algorithms · GitHub

Pick the one you like :slightly_smiling_face:

Regarding the conditional Sendable, the actual implementation does handles that.

This is not currently possible; for the same reason that Sequence et al cannot do that.

I tend to agree, lets do that since there is precedent APIs on result.

1 Like