Combine expert
Skill almasumdev/awesome-ios-agent-skills/.github/skills/concurrency_and_networking/combine-expert
Expert guidance on Combine publishers, subjects, operators, backpressure, error handling, and how to decide when to migrate to async/await. Use for Combine-heavy codebases.From its SKILL.md
npx -y skills add almasumdev/awesome-ios-agent-skills --skill combine-expertAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
2 things to look at
- no licenseNo license file was found in the repository. Code published without one is not open source by default, so using it at work is a question for whoever answers licensing questions where you are.
- 1 stars1 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
5.3 KB, ~1.3k tokens by cl100k_base, as published. Nobody here has run it
Combine Expert Patterns
Instructions
Combine is still the right tool when you're sitting on Apple's declarative SDK boundaries (@Published, NotificationCenter, NSObject KVO), or when your codebase hasn't yet migrated. Keep chains short, errors explicit, and subscriptions owned.
1. Publishers, Operators, Subscribers
import Combine
struct Search {
let results: AnyPublisher<[Article], Error>
static func make(query: AnyPublisher<String, Never>, api: API) -> Search {
let pub = query
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
.removeDuplicates()
.filter { $0.count >= 2 }
.map { api.search($0) }
.switchToLatest()
.eraseToAnyPublisher()
return Search(results: pub)
}
}
2. Subjects
PassthroughSubject— emits only to current subscribers (events).CurrentValueSubject— holds and emits current value (state).@Published— property wrapper sugar forCurrentValueSubjectsemantics.
final class Cart: ObservableObject {
@Published private(set) var items: [Item] = []
func add(_ item: Item) { items.append(item) }
}
3. Error Handling
Combine requires Failure types to match across operators. Use mapError, catch, replaceError at the boundary you want to neutralize:
apiPublisher
.retry(2)
.map(Output.success)
.catch { error -> Just<Output> in Just(.failure(error)) }
.receive(on: DispatchQueue.main)
.sink { output in self.handle(output) }
.store(in: &cancellables)
Use tryMap to introduce errors, mapError to translate them to your domain type.
4. Schedulers
DispatchQueue.mainfor UI side effects.DispatchQueue.global(qos: .userInitiated)for background transforms.RunLoop.mainfor UIKit animations that need the.commonmode.
Always receive(on:) before touching UI. Don't scatter .receive(on:) — put it as close to the sink as possible.
5. Backpressure
Combine has no formal backpressure, but operators control flow:
throttle(for:scheduler:latest:)— one event per window.debounce(for:scheduler:)— one event after a quiet period.collect(_:)— batch into arrays.switchToLatest()— cancel in-flight when a new inner publisher starts (searches, typeahead).
6. Combining Streams
Publishers.CombineLatest3(user, settings, features)
.map { UIState(user: $0.0, settings: $0.1, features: $0.2) }
.receive(on: DispatchQueue.main)
.assign(to: &$state) // &$state requires @Published
zip pairs events one-to-one; combineLatest fires on any upstream emission; merge interleaves same-type streams.
7. Testing
Use a test scheduler (e.g., combine-schedulers from Point-Free) to drive time deterministically:
import CombineSchedulers
func test_debounceSearch() {
let scheduler = DispatchQueue.test
let input = PassthroughSubject<String, Never>()
var outputs: [String] = []
let c = input
.debounce(for: .milliseconds(300), scheduler: scheduler)
.sink { outputs.append($0) }
input.send("a")
scheduler.advance(by: .milliseconds(299))
input.send("ab")
scheduler.advance(by: .milliseconds(300))
XCTAssertEqual(outputs, ["ab"])
_ = c
}
8. Owning Subscriptions
Every sink / assign returns an AnyCancellable. Store it — otherwise the subscription cancels immediately:
private var cancellables = Set<AnyCancellable>()
somePublisher
.sink { [weak self] in self?.handle($0) }
.store(in: &cancellables)
In view models, clear cancellables on deinit or when restarting a pipeline.
9. Bridging to async/await
Use .values to consume any publisher as an AsyncSequence:
for try await article in apiPublisher.values {
handle(article)
}
For a single value, use async extensions or a small helper:
extension Publisher {
func firstValue() async throws -> Output {
try await withCheckedThrowingContinuation { cont in
var c: AnyCancellable?
c = self.first()
.sink(receiveCompletion: { if case .failure(let e) = $0 { cont.resume(throwing: e) }; _ = c },
receiveValue: { cont.resume(returning: $0); _ = c })
}
}
}
10. When to Migrate
Migrate when:
- Operator chains exceed ~5 steps or nest
flatMaps — async/await is clearer. - You need structured cancellation tied to view lifetime (use
.task). - The team hits Swift 6
Sendablefriction from reference-heavy publishers.
Keep Combine when:
- You're consuming an Apple SDK that still vends publishers.
@Published+ObservableObjectis already widespread and migration cost outweighs benefits.
Checklist
- Every subscription is stored — no orphan pipelines.
-
receive(on:)is set immediately before UI sinks. - Error types are explicit; no
.eraseToAnyPublisher()after untyped errors. - Tests use a virtual scheduler for anything time-based.
- Migration plan exists for long-term move to
async/await.
What ships with it
Read from the repository
Just SKILL.md. No reference files, no scripts.