Skip to content

Commit 598ca12

Browse files
clive819phausler
authored andcommitted
Implement AsyncMapErrorSequence
1 parent 70c36ce commit 598ca12

File tree

3 files changed

+295
-0
lines changed

3 files changed

+295
-0
lines changed

Evolution/NNNN-map-error.md

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# Map Error
2+
3+
* Proposal: [SAA-NNNN](NNNN-map-error.md)
4+
* Authors: [Clive Liu](https://github.com/clive819)
5+
* Review Manager: TBD
6+
* Status: **Awaiting review**
7+
8+
*During the review process, add the following fields as needed:*
9+
10+
* Implementation: [apple/swift-async-algorithms#324](https://github.com/apple/swift-async-algorithms/pull/324)
11+
* Decision Notes:
12+
* Bugs:
13+
14+
## Introduction
15+
16+
The `mapError` function empowers developers to elegantly transform errors within asynchronous sequences, enhancing code readability and maintainability.
17+
18+
```swift
19+
extension AsyncSequence {
20+
21+
public func mapError<MappedFailure: Error>(_ transform: @Sendable @escaping (Self.Failure) -> MappedFailure) -> some AsyncSequence<Self.Element, MappedFailure> {
22+
AsyncMapErrorSequence(base: self, transform: transform)
23+
}
24+
}
25+
```
26+
27+
## Detailed design
28+
29+
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.
30+
31+
```swift
32+
struct AsyncMapErrorSequence<Base: AsyncSequence, MappedFailure: Error>: AsyncSequence {
33+
34+
...
35+
36+
func makeAsyncIterator() -> Iterator {
37+
Iterator(
38+
base: base.makeAsyncIterator(),
39+
transform: transform
40+
)
41+
}
42+
}
43+
44+
extension AsyncMapErrorSequence {
45+
46+
struct Iterator: AsyncIteratorProtocol {
47+
48+
typealias Element = Base.Element
49+
50+
private var base: Base.AsyncIterator
51+
52+
private let transform: @Sendable (Failure) -> MappedFailure
53+
54+
init(
55+
base: Base.AsyncIterator,
56+
transform: @Sendable @escaping (Failure) -> MappedFailure
57+
) {
58+
self.base = base
59+
self.transform = transform
60+
}
61+
62+
mutating func next() async throws(MappedFailure) -> Element? {
63+
do {
64+
return try await base.next(isolation: nil)
65+
} catch {
66+
throw transform(error)
67+
}
68+
}
69+
70+
mutating func next(isolation actor: isolated (any Actor)?) async throws(MappedFailure) -> Element? {
71+
do {
72+
return try await base.next(isolation: actor)
73+
} catch {
74+
throw transform(error)
75+
}
76+
}
77+
}
78+
}
79+
80+
extension AsyncMapErrorSequence: Sendable where Base: Sendable, Base.Element: Sendable {}
81+
```
82+
83+
## Naming
84+
85+
The naming follows to current method naming of the Combine [mapError](https://developer.apple.com/documentation/combine/publisher/maperror(_:)) method.
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2024 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See https://swift.org/LICENSE.txt for license information
9+
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
#if compiler(>=6.0)
14+
extension AsyncSequence {
15+
16+
/// Converts any failure into a new error.
17+
///
18+
/// - Parameter transform: A closure that takes the failure as a parameter and returns a new error.
19+
/// - Returns: An asynchronous sequence that maps the error thrown into the one produced by the transform closure.
20+
///
21+
/// Use the ``mapError(_:)`` operator when you need to replace one error type with another.
22+
@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
23+
public func mapError<MappedError: Error>(_ transform: @Sendable @escaping (Self.Failure) -> MappedError) -> some AsyncSequence<Self.Element, MappedError> {
24+
AsyncMapErrorSequence(base: self, transform: transform)
25+
}
26+
27+
/// Converts any failure into a new error.
28+
///
29+
/// - Parameter transform: A closure that takes the failure as a parameter and returns a new error.
30+
/// - Returns: An asynchronous sequence that maps the error thrown into the one produced by the transform closure.
31+
///
32+
/// Use the ``mapError(_:)`` operator when you need to replace one error type with another.
33+
@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
34+
public func mapError<MappedError: Error>(_ transform: @Sendable @escaping (Self.Failure) -> MappedError) -> (some AsyncSequence<Self.Element, MappedError> & Sendable) where Self: Sendable, Self.Element: Sendable {
35+
AsyncMapErrorSequence(base: self, transform: transform)
36+
}
37+
}
38+
39+
/// An asynchronous sequence that converts any failure into a new error.
40+
@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
41+
fileprivate struct AsyncMapErrorSequence<Base: AsyncSequence, MappedError: Error>: AsyncSequence {
42+
43+
typealias AsyncIterator = Iterator
44+
typealias Element = Base.Element
45+
typealias Failure = Base.Failure
46+
47+
private let base: Base
48+
private let transform: @Sendable (Failure) -> MappedError
49+
50+
init(
51+
base: Base,
52+
transform: @Sendable @escaping (Failure) -> MappedError
53+
) {
54+
self.base = base
55+
self.transform = transform
56+
}
57+
58+
func makeAsyncIterator() -> Iterator {
59+
Iterator(
60+
base: base.makeAsyncIterator(),
61+
transform: transform
62+
)
63+
}
64+
}
65+
66+
@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
67+
extension AsyncMapErrorSequence {
68+
69+
/// The iterator that produces elements of the map sequence.
70+
fileprivate struct Iterator: AsyncIteratorProtocol {
71+
72+
typealias Element = Base.Element
73+
74+
private var base: Base.AsyncIterator
75+
76+
private let transform: @Sendable (Failure) -> MappedError
77+
78+
init(
79+
base: Base.AsyncIterator,
80+
transform: @Sendable @escaping (Failure) -> MappedError
81+
) {
82+
self.base = base
83+
self.transform = transform
84+
}
85+
86+
mutating func next() async throws(MappedError) -> Element? {
87+
try await self.next(isolation: nil)
88+
}
89+
90+
mutating func next(isolation actor: isolated (any Actor)?) async throws(MappedError) -> Element? {
91+
do {
92+
return try await base.next(isolation: actor)
93+
} catch {
94+
throw transform(error)
95+
}
96+
}
97+
}
98+
}
99+
100+
@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
101+
extension AsyncMapErrorSequence: Sendable where Base: Sendable, Base.Element: Sendable {}
102+
103+
@available(*, unavailable)
104+
extension AsyncMapErrorSequence.Iterator: Sendable {}
105+
#endif
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import AsyncAlgorithms
2+
import XCTest
3+
4+
#if compiler(>=6.0)
5+
@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
6+
final class TestMapError: XCTestCase {
7+
8+
func test_mapError() async throws {
9+
let array: [Any] = [1, 2, 3, MyAwesomeError.originalError, 4, 5, 6]
10+
let sequence = array.async
11+
.map {
12+
if let error = $0 as? Error {
13+
throw error
14+
} else {
15+
$0 as! Int
16+
}
17+
}
18+
.mapError { _ in
19+
MyAwesomeError.mappedError
20+
}
21+
22+
var results: [Int] = []
23+
24+
do {
25+
for try await number in sequence {
26+
results.append(number)
27+
}
28+
XCTFail("sequence should throw")
29+
} catch {
30+
XCTAssertEqual(error, .mappedError)
31+
}
32+
33+
XCTAssertEqual(results, [1, 2, 3])
34+
}
35+
36+
func test_mapError_cancellation() async throws {
37+
let value = "test"
38+
let source = Indefinite(value: value).async
39+
let sequence = source
40+
.map {
41+
if $0 == "just to trick compiler that this may throw" {
42+
throw MyAwesomeError.originalError
43+
} else {
44+
$0
45+
}
46+
}
47+
.mapError { _ in
48+
MyAwesomeError.mappedError
49+
}
50+
51+
let finished = expectation(description: "finished")
52+
let iterated = expectation(description: "iterated")
53+
54+
let task = Task {
55+
var firstIteration = false
56+
for try await el in sequence {
57+
XCTAssertEqual(el, value)
58+
59+
if !firstIteration {
60+
firstIteration = true
61+
iterated.fulfill()
62+
}
63+
}
64+
finished.fulfill()
65+
}
66+
67+
// ensure the other task actually starts
68+
await fulfillment(of: [iterated], timeout: 1.0)
69+
// cancellation should ensure the loop finishes
70+
// without regards to the remaining underlying sequence
71+
task.cancel()
72+
await fulfillment(of: [finished], timeout: 1.0)
73+
}
74+
75+
func test_mapError_empty() async throws {
76+
let array: [String] = []
77+
let sequence = array.async
78+
.map {
79+
if $0 == "just to trick compiler that this may throw" {
80+
throw MyAwesomeError.originalError
81+
} else {
82+
$0
83+
}
84+
}
85+
.mapError { _ in
86+
MyAwesomeError.mappedError
87+
}
88+
89+
var results: [String] = []
90+
for try await value in sequence {
91+
results.append(value)
92+
}
93+
XCTAssert(results.isEmpty)
94+
}
95+
}
96+
97+
@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
98+
private extension TestMapError {
99+
100+
enum MyAwesomeError: Error {
101+
case originalError
102+
case mappedError
103+
}
104+
}
105+
#endif

0 commit comments

Comments
 (0)