Home

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.