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

📊 Operator Cheat Sheet

OperatorDoes
mapTransform each value
filterKeep values passing a test
debounceEmit after a quiet pause
combineLatestPair latest from each source
flatMapMap 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
// 60

Your 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
//    13

2️⃣ 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: swift

Now 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:
//    HELLO

Pro Tips

Common Errors (and the fix)

📋 Quick Reference

GoalCodeResult
Transform.map { $0 * 2 }double each
Screen.filter { $0 > 0 }keep positives
Settle.debounce(for:.seconds(1),…)wait then emit
Joina.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!

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

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.