Skip to content

Introduce mapError function #324

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions Evolution/NNNN-map-error.md
Original file line number Diff line number Diff line change
@@ -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.
105 changes: 105 additions & 0 deletions Sources/AsyncAlgorithms/AsyncMapErrorSequence.swift
Original file line number Diff line number Diff line change
@@ -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
105 changes: 105 additions & 0 deletions Tests/AsyncAlgorithmsTests/TestMapError.swift
Original file line number Diff line number Diff line change
@@ -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