|
| 1 | +# FlatMapLatest |
| 2 | + |
| 3 | +* Author(s): [Peter Friese](https://github.com/peterfriese) |
| 4 | + |
| 5 | +Transforms elements into asynchronous sequences, emitting elements from only the most recent inner sequence. |
| 6 | + |
| 7 | +[[Source](https://github.com/apple/swift-async-algorithms/blob/main/Sources/AsyncAlgorithms/FlatMapLatest/AsyncFlatMapLatestSequence.swift) | |
| 8 | +[Tests](https://github.com/apple/swift-async-algorithms/blob/main/Tests/AsyncAlgorithmsTests/TestFlatMapLatest.swift)] |
| 9 | + |
| 10 | +```swift |
| 11 | +let searchQuery = AsyncStream<String> { continuation in |
| 12 | + // User types into search field |
| 13 | + continuation.yield("swi") |
| 14 | + try? await Task.sleep(for: .milliseconds(100)) |
| 15 | + continuation.yield("swift") |
| 16 | + try? await Task.sleep(for: .milliseconds(100)) |
| 17 | + continuation.yield("swift async") |
| 18 | + continuation.finish() |
| 19 | +} |
| 20 | + |
| 21 | +let searchResults = searchQuery.flatMapLatest { query in |
| 22 | + performSearch(query) // Returns AsyncSequence<SearchResult> |
| 23 | +} |
| 24 | + |
| 25 | +for try await result in searchResults { |
| 26 | + print(result) // Only shows results from "swift async" |
| 27 | +} |
| 28 | +``` |
| 29 | + |
| 30 | +## Introduction |
| 31 | + |
| 32 | +When transforming elements of an asynchronous sequence into new asynchronous sequences, you often want to abandon previous sequences when new data arrives. This is particularly useful for scenarios like search-as-you-type, where each keystroke triggers a new search request and you only care about results from the most recent query. |
| 33 | + |
| 34 | +The `flatMapLatest` operator solves this by cancelling iteration on the previous inner sequence whenever a new element arrives from the outer sequence. |
| 35 | + |
| 36 | +## Proposed Solution |
| 37 | + |
| 38 | +The `flatMapLatest` algorithm transforms each element from the base `AsyncSequence` into a new inner `AsyncSequence` using the provided `transform` closure. When a new element is produced by the base sequence, iteration on the current inner sequence is cancelled, and iteration begins on the newly created sequence. |
| 39 | + |
| 40 | +```swift |
| 41 | +extension AsyncSequence where Self: Sendable { |
| 42 | + public func flatMapLatest<T: AsyncSequence & Sendable>( |
| 43 | + _ transform: @escaping @Sendable (Element) -> T |
| 44 | + ) -> AsyncFlatMapLatestSequence<Self, T> |
| 45 | +} |
| 46 | +``` |
| 47 | + |
| 48 | +This creates a concise way to express switching behavior: |
| 49 | + |
| 50 | +```swift |
| 51 | +userInput.flatMapLatest { input in |
| 52 | + fetchDataFromNetwork(input) |
| 53 | +} |
| 54 | +``` |
| 55 | + |
| 56 | +In this case, each new user input cancels any ongoing network request and starts a fresh one, ensuring only the latest data is delivered. |
| 57 | + |
| 58 | +## Detailed Design |
| 59 | + |
| 60 | +The type that implements the algorithm emits elements from the inner sequences. It throws when either the base type or any inner sequence throws. |
| 61 | + |
| 62 | +```swift |
| 63 | +public struct AsyncFlatMapLatestSequence<Base: AsyncSequence & Sendable, Inner: AsyncSequence & Sendable>: AsyncSequence, Sendable |
| 64 | + where Base.Element: Sendable, Inner.Element: Sendable { |
| 65 | + public typealias Element = Inner.Element |
| 66 | + |
| 67 | + public struct Iterator: AsyncIteratorProtocol, Sendable { |
| 68 | + public func next() async throws -> Element? |
| 69 | + } |
| 70 | + |
| 71 | + public func makeAsyncIterator() -> Iterator |
| 72 | +} |
| 73 | +``` |
| 74 | + |
| 75 | +The implementation uses a state machine to ensure thread-safe operation and generation tracking to prevent stale values from cancelled sequences. |
| 76 | + |
| 77 | +### Behavior |
| 78 | + |
| 79 | +- **Switching**: When a new element arrives from the base sequence, the current inner sequence is cancelled immediately |
| 80 | +- **Completion**: The sequence completes when the base sequence finishes and the final inner sequence completes |
| 81 | +- **Error Handling**: Errors from either the base or inner sequences are propagated immediately |
| 82 | +- **Cancellation**: Cancelling iteration cancels both the base and current inner sequence |
| 83 | + |
| 84 | +## Use Cases |
| 85 | + |
| 86 | +### Search as You Type |
| 87 | + |
| 88 | +```swift |
| 89 | +let searchField = AsyncStream<String> { continuation in |
| 90 | + // Emit search queries as user types |
| 91 | + continuation.yield("s") |
| 92 | + continuation.yield("sw") |
| 93 | + continuation.yield("swift") |
| 94 | +} |
| 95 | + |
| 96 | +let results = searchField.flatMapLatest { query in |
| 97 | + searchAPI(for: query) |
| 98 | +} |
| 99 | +``` |
| 100 | + |
| 101 | +Only the results for "swift" will be emitted, as earlier queries are cancelled. |
| 102 | + |
| 103 | +### Location-Based Data |
| 104 | + |
| 105 | +```swift |
| 106 | +let locationUpdates = CLLocationManager.shared.locationUpdates |
| 107 | + |
| 108 | +let nearbyPlaces = locationUpdates.flatMapLatest { location in |
| 109 | + fetchNearbyPlaces(at: location) |
| 110 | +} |
| 111 | +``` |
| 112 | + |
| 113 | +Each location update triggers a new search, cancelling any ongoing requests for previous locations. |
| 114 | + |
| 115 | +### Dynamic Configuration |
| 116 | + |
| 117 | +```swift |
| 118 | +let settings = userSettingsStream |
| 119 | + |
| 120 | +let data = settings.flatMapLatest { config in |
| 121 | + loadData(with: config) |
| 122 | +} |
| 123 | +``` |
| 124 | + |
| 125 | +When settings change, data loading is restarted with the new configuration. |
| 126 | + |
| 127 | +## Comparison with Other Operators |
| 128 | + |
| 129 | +**`map`**: Transforms elements synchronously without producing sequences. |
| 130 | + |
| 131 | +**`flatMap`** (if it existed): Would emit elements from all inner sequences concurrently, not cancelling previous ones. |
| 132 | + |
| 133 | +**`switchToLatest`** (Combine): Equivalent operator in Combine framework - `flatMapLatest` is the `AsyncSequence` equivalent. |
| 134 | + |
| 135 | +## Comparison with Other Libraries |
| 136 | + |
| 137 | +**ReactiveX** ReactiveX has an [API definition of switchMap](https://reactivex.io/documentation/operators/flatmap.html) which performs the same operation for Observables. |
| 138 | + |
| 139 | +**Combine** Combine has an [API definition of switchToLatest()](https://developer.apple.com/documentation/combine/publisher/switchtolatest()) which flattens a publisher of publishers by subscribing to the most recent one. |
0 commit comments