Home

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.