Combine expert
Skill almasumdev/awesome-ios-agent-skills/.github/skills/concurrency_and_networking/combine-expert
Curated agent skills, conventions, and workflows for building iOS apps (Swift, SwiftUI, UIKit) with AI coding agents.
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.
What its author says it does
Copied from the file, not written here
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.
SKILL.md
5.3 KB, 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.