Combine Operators & Pipelines
Reviewed & published by Brayan K
Operators are how you shape a Combine stream. This lesson covers map, filter, debounce, combineLatest, and flatMap, plus delivering results safely with receive(on:) and assign(to:).
Part of the free Swift course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn in This Lesson
- • Transform values with map and screen them with filter
- • Tame rapid input with debounce and removeDuplicates
- • Merge sources with combineLatest
- • Chain async work with flatMap
- • Deliver on the right thread with receive(on:)
- • Pipe results into a property with assign(to:on:)
📊 Operator Cheat Sheet
| Operator | Does |
|---|---|
| map | Transform each value |
| filter | Keep values passing a test |
| debounce | Emit after a quiet pause |
| combineLatest | Pair latest from each source |
| flatMap | Map to publisher, flatten |
| receive(on:) | Switch delivery queue |
| assign(to:on:) | Write into a property |
1️⃣ map and filter
filter screens values; map transforms what survives. Chained together they read top to bottom, just like the data flows.
import Combine
// Operators chain together to form a pipeline. Values flow top to bottom.
let token = [1, 2, 3, 4, 5, 6].publisher
.filter { $0 % 2 == 0 } // keep even numbers: 2, 4, 6
.map { $0 * 10 } // transform each: 20, 40, 60
.sink { print($0) }
// 20
// 40
// 60Your turn. Fill in the operator names.
// 🎯 YOUR TURN — replace each ___ then run it in SwiftFiddle.
import Combine
let token = [3, 6, 9, 12].publisher
.___ { $0 > 5 } // 👉 keep values greater than 5
.___ { $0 + 1 } // 👉 add 1 to each surviving value
.sink { print($0) }
// ✅ Expected output:
// 7
// 10
// 132️⃣ combineLatest
combineLatest joins two streams: whenever either emits, you get the newest value from each. It only begins once both have emitted at least once.
import Combine
// combineLatest pairs the LATEST value from each source whenever either fires.
let name = CurrentValueSubject<String, Never>("Sam")
let age = CurrentValueSubject<Int, Never>(20)
let token = name.combineLatest(age)
.map { n, a in "\(n) is \(a)" }
.sink { print($0) }
age.send(21) // name still "Sam", age now 21
name.send("Alex") // age still 21, name now "Alex"3️⃣ debounce, receive(on:), and assign
debounce waits for a pause before emitting — ideal for search-as-you-type. receive(on:) moves delivery to the main thread for UI safety, and assign(to:on:) writes straight into a property.
import Combine
import Foundation
// debounce waits for a pause before emitting — perfect for search fields.
// receive(on:) makes results arrive on the main thread for safe UI updates.
let searchText = PassthroughSubject<String, Never>()
let token = searchText
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
.removeDuplicates()
.receive(on: DispatchQueue.main)
.sink { query in
print("Searching for: \(query)")
}
// As the user types quickly, only the settled value triggers a search.
searchText.send("s")
searchText.send("sw")
searchText.send("swift")
// After ~300ms of quiet: Searching for: swiftNow you try. Fill in the operator that assigns into a property.
// 🎯 YOUR TURN — pipe values straight into a property.
import Combine
class Label { var text: String = "" }
let label = Label()
let source = PassthroughSubject<String, Never>()
let token = source
.map { $0.uppercased() }
.___(to: \.text, on: label) // 👉 the operator that writes into label.text
source.send("hello")
print(label.text)
// ✅ Expected output:
// HELLOPro Tips
- 💡 Put receive(on: .main) just before UI work so the rest of the pipeline can run off the main thread.
- 💡 Pair debounce with removeDuplicates to skip redundant searches.
- 💡 Reach for flatMap when each value triggers its own async publisher (like a request).
- 💡 Keep pipelines short. If a chain grows long, extract a helper publisher.
Common Errors (and the fix)
- UI updates on a background thread — add receive(on: DispatchQueue.main) before your sink/assign.
- combineLatest never emits — one of the sources hasn't produced a first value yet.
- Too many searches firing — you forgot debounce on the input stream.
- flatMap type mismatch — its closure must return a Publisher, not a plain value (that's map).
📋 Quick Reference
| Goal | Code | Result |
|---|---|---|
| Transform | .map { $0 * 2 } | double each |
| Screen | .filter { $0 > 0 } | keep positives |
| Settle | .debounce(for:.seconds(1),…) | wait then emit |
| Join | a.combineLatest(b) | latest pair |
| Main thread | .receive(on:.main) | UI-safe |
| To property | .assign(to:\.text, on:label) | set label.text |
Mini-Challenge: Live Word Counter
Build a pipeline that counts words as sentences arrive. Run it and compare with the comments.
// 🎯 MINI-CHALLENGE: Live word counter
// 1. Make a PassthroughSubject<String, Never> called 'input'.
// 2. Build a pipeline that:
// - maps each string to its word count (split on spaces),
// - removes consecutive duplicate counts,
// - prints "words: <n>".
// 3. Send a few sentences and watch the counts print.
//
// ✅ Example output for "hi there" then "hi there now":
// words: 2
// words: 3
// your code here🎉 Lesson Complete!
- ✅ map transforms, filter screens
- ✅ debounce waits for a pause; removeDuplicates skips repeats
- ✅ combineLatest pairs the latest from each source
- ✅ flatMap maps to a publisher and flattens it
- ✅ receive(on:) picks the delivery queue; assign(to:on:) writes a property
Practice quiz
What does the map operator do in Combine?
- Transforms each emitted value
- Filters values
- Delays values
- Combines two publishers
Answer: Transforms each emitted value. map transforms every value the upstream publisher emits.
Which operator only lets through values that pass a test?
- map
- merge
- filter
- assign
Answer: filter. filter forwards only values for which the closure returns true.
What problem does debounce solve?
- Sorting values
- Waiting for a pause before emitting, e.g. for search text
- Combining errors
- Caching to disk
Answer: Waiting for a pause before emitting, e.g. for search text. debounce waits for a quiet interval, ideal for rapidly changing input like typing.
combineLatest emits when...
- Only the first publisher emits
- All publishers finish
- An error occurs
- Any input publisher emits, pairing the latest value from each
Answer: Any input publisher emits, pairing the latest value from each. combineLatest emits the most recent value from each source whenever any of them changes.
What does flatMap return for each value?
- A new publisher whose values are flattened into the stream
- A single value
- An array
- Nothing
Answer: A new publisher whose values are flattened into the stream. flatMap maps each value to a publisher and flattens the emissions into one stream.
Why use receive(on: DispatchQueue.main)?
- To speed up the network
- To deliver downstream values on the main thread for UI updates
- To filter values
- To cancel the subscription
Answer: To deliver downstream values on the main thread for UI updates. receive(on:) switches delivery to a chosen queue, e.g. main for UI work.
What does assign(to:on:) do?
- Transforms values
- Delays values
- Merges streams
- Writes each received value into a property via a key path
Answer: Writes each received value into a property via a key path. assign(to:on:) sets a value onto an object's property using a key path.
After debounce, which value is emitted?
- The first value
- The most recent value once the pause elapses
- A random value
- All buffered values
Answer: The most recent value once the pause elapses. debounce emits only the latest value after the specified quiet period.
Operators in Combine are chained to form a...
- Pipeline that transforms the stream step by step
- Database
- View hierarchy
- Single function
Answer: Pipeline that transforms the stream step by step. Chained operators build a pipeline that processes values as they flow.
removeDuplicates does what?
- Sorts the stream
- Adds delays
- Drops a value equal to the one immediately before it
- Removes all values
Answer: Drops a value equal to the one immediately before it. removeDuplicates suppresses consecutive equal values.
Continue this course
- Previous: Introduction to Combine
- Next: Advanced Concurrency: async let, TaskGroup & Actors — Parallel async let, task groups, actors and @MainActor isolation
Frequently asked questions
What is the difference between map and flatMap?
map transforms each value into another value of any type, keeping a one-to-one flow. flatMap transforms each value into a whole new publisher and flattens those publishers' emissions into a single downstream stream. Use flatMap when each value needs to kick off its own asynchronous work, such as a network request per input.
Why do I need receive(on:)?
Combine pipelines can run work on background queues, but UI updates must happen on the main thread. receive(on: DispatchQueue.main) switches delivery of downstream values to the main queue so your sink or assign safely updates the interface. Place it just before the operators or subscribers that touch the UI.
When should I use debounce?
Use debounce when a publisher emits rapidly but you only care about the value once things settle, such as text in a search box. It waits for a quiet interval you specify and then emits the most recent value, avoiding a flood of work for every keystroke. throttle is a related operator that emits at most once per interval.
What does combineLatest do when one publisher hasn't emitted yet?
combineLatest only starts emitting once every source publisher has produced at least one value, because it needs a latest value from each to form a pair. After that, it re-emits whenever any source changes, pairing the new value with the latest values from the others.
How is assign(to:on:) different from sink?
Both are subscribers. sink runs a closure for each value, giving you full control. assign(to:on:) is a shortcut that writes each received value directly into a property using a key path, e.g. .assign(to: \.text, on: label). It is handy when the only thing you want to do is set a property.