Combining
merge, combineLatest and zip — and why the choice is not obvious
Three operators take several streams and produce one. They are easy to confuse and behave completely differently the moment one source is slower than the others.
merge interleaves. All inputs must be the same type; every value passes straight through as it
arrives.
let allEvents = pushNotifications.merge(with: localNotifications)
combineLatest emits whenever any input emits, combining it with the most recent value from
each of the others. Types may differ. It emits nothing until every input has produced at least one
value.
let canSubmit = Publishers.CombineLatest3($email, $password, $agreedToTerms)
.map { email, password, agreed in
email.contains("@") && password.count >= 8 && agreed
}
zip pairs values by index — first with first, second with second — and waits for every input
to supply one.
let pairs = names.zip(avatars) // waits for both, emits (name, avatar)
Choosing between them
The question to ask: when one source emits, should the others’ old values be reused?
combineLatest says yes — that is exactly its job, and it is the right answer for form validation,
a filter bar, or anything where you want “the current state of all inputs”. Three text fields plus a
toggle, combined, gives you a canSubmit that is correct after every keystroke.
zip says no — it waits for a fresh value from each. Right when values are genuinely paired: a list
of ids zipped with the results of fetching them, two parallel requests whose results belong together.
merge says the question does not apply, because the streams are alternative sources of the same
kind of event rather than components of one state.
Warning
zip on sources with different rates is a memory leak with extra steps. A fast publisher zipped
with a slow one buffers every unmatched value from the fast side, forever. zip a
once-per-second timer with a once-a-minute one and the buffer grows by sixty values a minute
with nothing to pair them against.
The combineLatest startup trap
Nothing is emitted until every input has produced a value. A CombineLatest3 where one input is a
PassthroughSubject that has not fired yet produces nothing at all — no initial state, no
validation, a submit button that stays disabled and no error to explain why.
The fixes, in order of preference:
// 1. Use CurrentValueSubject or @Published, which always have a value
let filter = CurrentValueSubject<Filter, Never>(.all)
// 2. Give the stream a starting value
subject.prepend(Filter.all)
// 3. Make it optional and handle nil
subject.map(Optional.some).prepend(nil)
The first is nearly always right. If a value represents state that always exists, model it with a type that always has one.
First-value operators
For a one-shot answer rather than an ongoing stream:
publisher.first() // first value, then finish
publisher.first(where: { $0 > 10 }) // first matching value
publisher.prefix(3) // first three, then finish
publisher.prefix(while: { $0 < 100 }) // until the condition fails
first() is how a stream becomes a one-shot request, and is the natural pairing with
.values.first(where:) when bridging to async/await.
prefix(untilOutputFrom:) is the one worth remembering: take values until some other publisher
emits. That is “keep going until the user cancels” written as one operator.
progressUpdates
.prefix(untilOutputFrom: cancelTapped)
.sink { … }
Ordering, and what combineLatest costs
combineLatest emits once per input change, which means a form with five fields emits five times
while a user pastes a value into each. If a downstream operation is expensive — a network
validation, a large recomputation — that is five of them.
Two operators fix it. debounce waits for the burst to settle; removeDuplicates() drops emissions
where the combined result did not actually change. The pair is idiomatic after any wide
combineLatest:
Publishers.CombineLatest3($a, $b, $c)
.map(Query.init)
.removeDuplicates()
.debounce(for: .milliseconds(200), scheduler: RunLoop.main)
.sink { runExpensiveSearch($0) }
Note the order: deduplicate before debouncing, so identical values collapse without resetting the debounce timer.