diff --git a/Evolution/NNNN-map-error.md b/Evolution/NNNN-map-error.md new file mode 100644 index 00000000..2d4c344a --- /dev/null +++ b/Evolution/NNNN-map-error.md @@ -0,0 +1,85 @@ +# Map Error + +* Proposal: [SAA-NNNN](NNNN-map-error.md) +* Authors: [Clive Liu](https://github.com/clive819) +* Review Manager: TBD +* Status: **Awaiting review** + +*During the review process, add the following fields as needed:* + +* Implementation: [apple/swift-async-algorithms#324](https://github.com/apple/swift-async-algorithms/pull/324) +* Decision Notes: +* Bugs: + +## Introduction + +The `mapError` function empowers developers to elegantly transform errors within asynchronous sequences, enhancing code readability and maintainability. + +```swift +extension AsyncSequence { + + public func mapError<MappedFailure: Error>(_ transform: @Sendable @escaping (Self.Failure) -> MappedFailure) -> some AsyncSequence<Self.Element, MappedFailure> { + AsyncMapErrorSequence(base: self, transform: transform) + } +} +``` + +## Detailed design + +The function iterates through the elements of an `AsyncSequence` within a do-catch block. If an error is caught, it calls the `transform` closure to convert the error into a new type and then throws it. + +```swift +struct AsyncMapErrorSequence<Base: AsyncSequence, MappedFailure: Error>: AsyncSequence { + + ... + + func makeAsyncIterator() -> Iterator { + Iterator( + base: base.makeAsyncIterator(), + transform: transform + ) + } +} + +extension AsyncMapErrorSequence { + + 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) + } + } + } +} + +extension AsyncMapErrorSequence: Sendable where Base: Sendable, Base.Element: Sendable {} +``` + +## Naming + +The naming follows to current method naming of the Combine [mapError](https://developer.apple.com/documentation/combine/publisher/maperror(_:)) method. diff --git a/Sources/AsyncAlgorithms/AsyncMapErrorSequence.swift b/Sources/AsyncAlgorithms/AsyncMapErrorSequence.swift new file mode 100644 index 00000000..75008f41 --- /dev/null +++ b/Sources/AsyncAlgorithms/AsyncMapErrorSequence.swift @@ -0,0 +1,105 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +#if compiler(>=6.0) +extension AsyncSequence { + + /// 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 ``mapError(_:)`` 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 mapError<MappedError: Error>(_ transform: @Sendable @escaping (Self.Failure) -> MappedError) -> some AsyncSequence<Self.Element, MappedError> { + AsyncMapErrorSequence(base: self, transform: transform) + } + + /// 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 ``mapError(_:)`` 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 mapError<MappedError: Error>(_ transform: @Sendable @escaping (Self.Failure) -> MappedError) -> (some AsyncSequence<Self.Element, MappedError> & Sendable) where Self: Sendable, Self.Element: Sendable { + AsyncMapErrorSequence(base: self, transform: transform) + } +} + +/// 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, *) +fileprivate struct AsyncMapErrorSequence<Base: AsyncSequence, MappedError: Error>: AsyncSequence { + + typealias AsyncIterator = Iterator + typealias Element = Base.Element + typealias Failure = Base.Failure + + private let base: Base + private let transform: @Sendable (Failure) -> MappedError + + init( + base: Base, + transform: @Sendable @escaping (Failure) -> MappedError + ) { + 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 AsyncMapErrorSequence { + + /// The iterator that produces elements of the map sequence. + fileprivate struct Iterator: AsyncIteratorProtocol { + + typealias Element = Base.Element + + private var base: Base.AsyncIterator + + private let transform: @Sendable (Failure) -> MappedError + + init( + base: Base.AsyncIterator, + transform: @Sendable @escaping (Failure) -> MappedError + ) { + self.base = base + self.transform = transform + } + + mutating func next() async throws(MappedError) -> Element? { + try await self.next(isolation: nil) + } + + mutating func next(isolation actor: isolated (any Actor)?) async throws(MappedError) -> 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 AsyncMapErrorSequence: Sendable where Base: Sendable, Base.Element: Sendable {} + +@available(*, unavailable) +extension AsyncMapErrorSequence.Iterator: Sendable {} +#endif diff --git a/Tests/AsyncAlgorithmsTests/TestMapError.swift b/Tests/AsyncAlgorithmsTests/TestMapError.swift new file mode 100644 index 00000000..afe90e13 --- /dev/null +++ b/Tests/AsyncAlgorithmsTests/TestMapError.swift @@ -0,0 +1,105 @@ +import AsyncAlgorithms +import XCTest + +#if compiler(>=6.0) +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +final class TestMapError: XCTestCase { + + func test_mapError() async throws { + let array: [Any] = [1, 2, 3, MyAwesomeError.originalError, 4, 5, 6] + let sequence = array.async + .map { + if let error = $0 as? Error { + throw error + } else { + $0 as! Int + } + } + .mapError { _ in + MyAwesomeError.mappedError + } + + var results: [Int] = [] + + do { + for try await number in sequence { + results.append(number) + } + XCTFail("sequence should throw") + } catch { + XCTAssertEqual(error, .mappedError) + } + + XCTAssertEqual(results, [1, 2, 3]) + } + + func test_mapError_cancellation() async throws { + let value = "test" + let source = Indefinite(value: value).async + let sequence = source + .map { + if $0 == "just to trick compiler that this may throw" { + throw MyAwesomeError.originalError + } else { + $0 + } + } + .mapError { _ in + MyAwesomeError.mappedError + } + + let finished = expectation(description: "finished") + let iterated = expectation(description: "iterated") + + let task = Task { + var firstIteration = false + for try await el in sequence { + XCTAssertEqual(el, value) + + if !firstIteration { + firstIteration = true + iterated.fulfill() + } + } + finished.fulfill() + } + + // ensure the other task actually starts + await fulfillment(of: [iterated], timeout: 1.0) + // cancellation should ensure the loop finishes + // without regards to the remaining underlying sequence + task.cancel() + await fulfillment(of: [finished], timeout: 1.0) + } + + func test_mapError_empty() async throws { + let array: [String] = [] + let sequence = array.async + .map { + if $0 == "just to trick compiler that this may throw" { + throw MyAwesomeError.originalError + } else { + $0 + } + } + .mapError { _ in + MyAwesomeError.mappedError + } + + var results: [String] = [] + for try await value in sequence { + results.append(value) + } + XCTAssert(results.isEmpty) + } +} + +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +private extension TestMapError { + + enum MyAwesomeError: Error { + case originalError + case mappedError + } +} +#endif