Transforming
One stream in, one stream out — and the flatMap decision
The simple ones behave exactly as their Sequence namesakes, applied to values as they arrive rather
than to a collection that already exists:
publisher
.map { $0 * 2 } // transform each value
.filter { $0 > 10 } // pass some through
.compactMap { Int($0) } // transform, dropping nils
.removeDuplicates() // drop consecutive equal values
.replaceNil(with: 0) // Optional stream to non-optional
Two that have no direct Sequence equivalent are worth knowing:
scan accumulates and emits the running total every time, where reduce would emit only at the
end:
taps.scan(0) { count, _ in count + 1 } // 1, 2, 3, 4 …
collect goes the other way, gathering values into an array. Bare collect() waits for the
stream to finish, which for an infinite stream means forever — collect(3) or
collect(.byTime(scheduler, .seconds(1))) are the useful forms.
The decision that matters: flatMap or switchToLatest
Both take a stream of values and turn each into a new publisher — a search term into a network request, a user id into a profile fetch. They differ in what happens to the previous one, and choosing wrongly is the most common serious bug in Combine code.
flatMap keeps them all. Every inner publisher runs to completion, and their values interleave
in whatever order they arrive.
switchToLatest cancels the previous one as soon as a new value arrives upstream.
// Wrong for search-as-you-type
searchTerms
.flatMap { api.search($0) }
.sink { showResults($0) }
// Right
searchTerms
.map { api.search($0) }
.switchToLatest()
.sink { showResults($0) }
Type “sw”, “swi”, “swif”, “swift” and the first version fires four requests and displays whichever returns last. Network timing being what it is, that is frequently the results for “swi” — the user sees results for a query they have already finished typing past, and no amount of debouncing fully fixes it, because debounce reduces the number of requests without ordering them.
The rule: if only the newest result is meaningful, use switchToLatest. Search, autocomplete,
a detail screen following a selection, anything driven by “the user is currently looking at X”.
Use flatMap when every result matters — uploading a queue of files, processing a batch, firing
analytics events. And when you do, set the concurrency limit:
uploads
.flatMap(maxPublishers: .max(3)) { upload($0) }
Warning
flatMap’s default maxPublishers is .unlimited. Mapping a thousand ids through it starts a
thousand simultaneous requests, which will exhaust URLSession’s connection pool and may get you
rate-limited. There is no situation where the unlimited default is a considered decision.
flatMap and the error type
flatMap requires the inner publisher’s Failure to match the outer one’s. This is the source of a
great deal of type-checker misery, and the fix is usually to handle the inner error inside the
closure:
searchTerms // Failure == Never
.map { term in
api.search(term) // Failure == APIError
.catch { _ in Just([Result]()) } // now Failure == Never
}
.switchToLatest()
Placing the catch inside is not a style choice. Outside, the first failed request would terminate
the whole searchTerms stream and the search field would stop working for the rest of the session.
Inside, one request fails, returns an empty array, and the next keystroke works normally.
setFailureType(to:) converts a Never-failing publisher to a failing type when you need the
reverse direction, and produces no runtime behaviour at all — it exists purely to satisfy the type
checker.
share() and multiple subscribers
A cold publisher does its work once per subscriber:
let request = api.fetchProfile()
request.sink { updateHeader($0) }.store(in: &cancellables)
request.sink { updateBody($0) }.store(in: &cancellables) // second network call
share() gives one upstream subscription that both subscribers observe:
let request = api.fetchProfile().share()
The catch: share() is PassthroughSubject-like, so a subscriber that arrives after a value was
emitted misses it entirely. For a request that may already have completed, .multicast { CurrentValueSubject(…) }
or caching the result yourself is the safer shape.