The contract
The four-step handshake underneath every stream
Between sink and the first value there is a handshake, and almost everything surprising about
Combine is explained by it.
- Subscribe. The subscriber is handed to the publisher via
receive(subscriber:). - Subscription. The publisher creates a
Subscription— the per-subscriber state — and hands it back withreceive(subscription:). - Demand. The subscriber calls
subscription.request(_:)with how many values it wants. Until it does, nothing is sent. - Values, then completion. The publisher sends up to that many values, each returning more
demand, and eventually one
.finishedor.failure.
protocol Subscriber<Input, Failure> {
func receive(subscription: Subscription)
func receive(_ input: Input) -> Subscribers.Demand
func receive(completion: Subscribers.Completion<Failure>)
}
Note the return type of the middle method. Every value delivered is an opportunity to ask for more, which is how demand accumulates without a separate channel.
Writing one, once
Nobody writes custom subscribers in production. Writing one once, in a playground, is still the fastest way to stop finding Combine mysterious:
final class OneAtATime: Subscriber {
typealias Input = Int
typealias Failure = Never
func receive(subscription: Subscription) {
subscription.request(.max(1)) // ask for exactly one
}
func receive(_ input: Int) -> Subscribers.Demand {
print("got \(input)")
return .max(1) // having consumed one, ask for one more
}
func receive(completion: Subscribers.Completion<Never>) {
print("done: \(completion)")
}
}
(1...5).publisher.subscribe(OneAtATime())
Change .max(1) to .none in receive(_:) and the stream delivers exactly one value and then
stops forever — no completion, no error, just silence. That is not a bug; that is the contract
working. The publisher is not permitted to send a second value because nobody asked for one.
Tip
That silence is worth producing deliberately once, because it is exactly what a stalled Combine pipeline looks like in production: no crash, no log, values simply stop. Recognising the shape saves hours later.
What sink and assign really are
Both are Subscribers.Sink and Subscribers.Assign under a convenience method, and both request
.unlimited immediately. That is why you never think about demand in normal use — and why a fast
publisher into a slow sink buffers without limit rather than slowing down.
assign(to:on:) writes each value to a key path on an object:
model.$name
.assign(to: \.text, on: label)
.store(in: &cancellables)
Warning
assign(to:on:) holds a strong reference to on:. Assigning to a property of self from a
stream stored in self is a retain cycle, and a common one. Use sink { [weak self] in … }, or
assign(to: &$published) — the &-taking overload used with @Published manages the lifetime
for you and does not retain.
Cancellable and what cancelling does
sink and assign return an AnyCancellable. It has one job: on deinit, it calls cancel(),
which tears down the subscription and releases the chain.
This is why the stored Set<AnyCancellable> exists, and why forgetting .store(in:) produces a
pipeline that runs for zero values — the cancellable is deallocated at the end of the function, and
the subscription with it.
Cancellation propagates upstream: cancel at the sink and every operator above it is torn down, up
to and including a URLSession request, which is genuinely cancelled rather than merely ignored.
The type explosion, and eraseToAnyPublisher
Every operator wraps its upstream in a new generic type, so a five-operator chain has a type name like:
Publishers.RemoveDuplicates<Publishers.Debounce<Publishers.CompactMap<…>, RunLoop>>
You cannot write that in an API, so erase it at the boundary:
func searchResults(for query: String) -> AnyPublisher<[Result], Never> {
api.search(query)
.replaceError(with: [])
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
Erase at the edge of a type, not between every operator. AnyPublisher boxes the chain and adds a
layer of indirection per value — negligible once, wasteful if you do it four times in one pipeline.