How do flatMap and switchToLatest differ, and when do you use each?
Predict first
Two inner subjects are chosen in turn. What does each approach output after the same sequence of sends?
import Combine
var bag = Set<AnyCancellable>()
let o1 = PassthroughSubject<Int, Never>(), o2 = PassthroughSubject<Int, Never>()
let i1 = [1: PassthroughSubject<String, Never>(), 2: PassthroughSubject<String, Never>()]
let i2 = [1: PassthroughSubject<String, Never>(), 2: PassthroughSubject<String, Never>()]
var fm: [String] = [], sw: [String] = []
o1.flatMap { i1[$0]! }.sink { fm.append($0) }.store(in: &bag)
o2.map { i2[$0]! }.switchToLatest().sink { sw.append($0) }.store(in: &bag)
o1.send(1); o1.send(2); i1[1]!.send("a1"); i1[2]!.send("b1"); i1[1]!.send("a2")
o2.send(1); o2.send(2); i2[1]!.send("a1"); i2[2]!.send("b1"); i2[1]!.send("a2")
print(fm, sw)
- ["a1", "b1", "a2"] ["b1"]
- ["b1"] ["a1", "b1", "a2"]
- ["a1", "b1", "a2"] ["a1", "b1", "a2"]
Mental model. flatMap keeps every pizza order cooking and serves them as they finish. switchToLatest throws away the old order when you change your mind and cooks only the newest.
flatMap turns each upstream value into another publisher and merges all of those inner publishers’ outputs into one stream. It keeps every inner publisher alive: if value 1 starts a request and value 2 starts another, both run, and their results interleave in whatever order they arrive (so results can come back out of order). flatMap(maxPublishers:) limits how many inner publishers run at once (with .max(1) it processes them one at a time, like a queue). switchToLatest works on a publisher of publishers (usually after a map): when a new inner publisher arrives it cancels the previous one and forwards only the newest. The rule of thumb: use flatMap when every inner result matters (save each item, process every event) and switchToLatest when only the latest matters (search as you type, where an older response must not overwrite a newer one).
Both flatten, so they change the failure model: an inner publisher’s failure fails the whole outer pipeline unless you catch it inside the closure (.catch { _ in Empty() } or replaceError on the inner publisher), which is the usual fix for “my search stopped working after one error”. Typical shape for search: $query.debounce(…).removeDuplicates().map { api.search($0).catch { _ in Just([]) } }.switchToLatest().receive(on: DispatchQueue.main).assign(to: &$results). With async code, the equivalent of switchToLatest is cancelling the previous Task (or .task(id:)), and the equivalent of flatMap is a task group.
flatMap keeps both inner publishers alive, so all three values arrive. switchToLatest cancelled the first inner one when the second arrived, so only "b1" gets through.
Common traps
- Using
flatMapfor search-as-you-type. An older, slower response can arrive last and overwrite the newest. - Letting an inner failure escape. It ends the whole pipeline; catch it inside the closure.
They might follow up with
- How do you process inner publishers strictly one at a time?
flatMap(maxPublishers: .max(1)). - What is the async equivalent of
switchToLatest? Cancel the previous task when a new query starts, for example with.task(id: query).
Related questions
- 11.2.3 What do debounce, throttle and removeDuplicates do, and what do share and multicast change?
- 11.5.2 How do failures work in Combine, what does Never mean, and how do catch, replaceError, retry and mapError…
- 11.5.1 What is AnyCancellable, how long does a subscription live, and how do you manage it in a view model?
← How do you read a pipeline, and how do combineLatest, merge, zip and… What do debounce, throttle and removeDuplicates do, and what do share… →