Swift Async Algorithms Proposal: mapError

Map Error

Introduction

Asynchronous sequences often particpate in carefully crafted systems of algorithms spliced together. Some of those algorithms require that both the element type and the failure type are the same as each-other. In order to line those types up for the element we have the map algorithm today, however there are other times at which when using more specific APIs that transforming the Failure type is needed.

Motivation

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

Building failure-type-safe versions of zip or other algorithms will need to require that the associated Failure types are the same. Having an effecient and easy to use transformation routine to adjust the failure-types is then key to delivering or interfacing with those failure-type-safe algorithms.

Proposed solution

A new extension and type will be added to transform the failure-types of AsyncSequences.

Detailed design

The method will be applied to all AsyncSequences via an extension with the function name of mapError. This is spiritually related to the mapError method on Result and similar in functionality to other frameworks' methods of the similar naming. This will not return an opaque result since the type needs to be refined for Sendable; in that the AsyncMapERrorSequence is only Sendable when the base AsyncSequence is Sendable.

extension AsyncSequence {
    public func mapError<MappedFailure: Error>(_ transform: @Sendable @escaping (Failure) async -> MappedFailure) -> AsyncMapErrorSequence<Element, MappedFailure>
}

public struct AsyncMapErrorSequence<Base: AsyncSequence, TransformedFailure: Error>: AsyncSequence { }

extension AsyncMapErrorSequence: Sendable where Base: Sendable { }

@available(*, unavailable)
extension AsyncMapErrorSequence.Iterator: Sendable {}

Effect on API resilience

This cannot be back-deployed to 1.0 since it has a base requirement for the associated Failure and requires typed throws.

Naming

The naming follows to current method naming of the Combine mapError method and similarly the name of the method on Result

Alternatives considered

It was initially considered that the return type would be opaque, however the only way to refine that as Sendable would be to have a disfavored overload; this ended up creating more ambiguity than it seemed worth.

8 Likes

It's fantastic except for the name.

The error type is "Failure", not "Error". So "mapError" is unspecific and confusing. mapFailure is the precisely correct name.

Result's method is named wrong. Please do not name this wrong too just because that existed already.

3 Likes

<Review manager hat>This was the original author's naming that referenced Combine as well which is also named mapError.</Review manager hat>

My own non review manager perspective is that I agree that mapFailure is a much better name because it is mapping the case of failure not mapping the existential of Error. But in my opinion it is better to have consistency that is slightly sub-par than a better name and be inconsistent. If we plan on pitching renaming Result's method then I would also be interested in naming this the same.

P.S. for historical archives I did find some discussion circa 2018 here but that didn't really go anywhere.

3 Likes

Quick thought, how about releasing both and deprecating mapError in favor of adopting mapFailure?

3 Likes

Things have gotten more complex since 2018. The first overload is the form it should be taking nowadays* , but that's not the only possibility—there's a special case available.

extension Result {
  func mapFailure<NewFailure, Error>(
    _ transform: (Failure) throws(Error) -> NewFailure
  ) throws(Error) -> Result<Success, NewFailure> {
    do { return try .success(get()) }
    catch { return try .failure(transform(error)) }
  }

  func mapFailureAndMergeError<NewFailure>(
    _ transform: (Failure) throws(NewFailure) -> NewFailure
  ) -> Result<Success, NewFailure> {
    do { return try mapFailure(transform) }
    catch { return .failure(error) }
  }
}

The state of Swift concurrency brings its own challenges here, because typed failures aren't always available, and the asynchronous transformations forbid some of the ability to avoid any Error that you get with synchronous code.

* I don't understand how to use consuming/consume properly yet.

Should the closure be sending rather than Sendable? Though this may be better done “all together” for many operations at once perhaps hmm…

Cycling back to this; I think the proposal's intent is to mimic what the standard library is doing - if there is an effort to add a method to result called mapFailure then that should be reflected here too.

Per the Sendable annotation; the instance needs to be sendable when the base sequence is sendable that then means any contents need to be sendable as well. Hence I think just using sending is insufficient.

2 Likes

Overall +1 on the proposal. A few minor comments:

  1. I agree with the naming suggesting to name this mapFailure to align with the generic type name
  2. Can we return an opaque some AsyncSequence<Element, MappedFailure> instead. This allows us to not expose the concrete type at all
  3. I agree with @Philippe_Hausler assessment that the closure needs to be @Sendable. We can't be generic over the closure's sendability otherwise we could spell it like Closure: Sendable where Base: Sendable.
2 Likes

If someone wants to pitch mapFailure to result then please do so... This proposal is not going to name things inconsistently.

The opaque result is impossible to write correctly - the return type must be conditionally sendable and then ends up requiring an overload that creates ambiguity: this is addressed in the proposal.

1 Like

I have been trying to create a situation where this leads to ambiguity but haven't been able in my tests so far. This was the interface and test that I used:

extension AsyncSequence {
    @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
    public func mapError<MappedError: Error>(
        _ transform: @Sendable @escaping (Self.Failure
    ) -> MappedError) -> some AsyncSequence<Self.Element, MappedError>
    @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
    public func mapError<MappedError: Error>(
        _ transform: @Sendable @escaping (Self.Failure) -> MappedError
    ) -> (some AsyncSequence<Self.Element, MappedError> & Sendable) where Self: Sendable, Self.Element: Sendable
}

// Sendable async sequence
let stream = AsyncThrowingStream.makeStream(of: Int.self).stream
stream.mapError { _ in
    return CancellationError()
}

// Non-sendable async sequence
let nonSendable = NoNSendableAsyncSequence()
nonSendable.mapError { _ in
    return CancellationError()
}

I was getting ambiguity errors with a very similar setup on the build of Swift I was using while updating this proposal. I would prefer not introducing new AsyncSequence types and favoring opaques if at all possible, but not at the cost of obscure ambiguity errors, or worse yet favoring the wrong overload.

If we can get a promise from the compiler folks that is going to be a supported mechanism then I am fine with changing it to be opaque.

Cycling back to this one: I was able to verify that the ambiguity is not an issue and is within the current specification of the compiler's behavior that the opaque version will work as expected - I will update the pitch shortly reflecting that.

4 Likes