agentsclimarketplace

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

Install
npx -y skills add almasumdev/awesome-ios-agent-skills --skill combine-expert

Assembled 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 for CurrentValueSubject semantics.
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.main for UI side effects.
  • DispatchQueue.global(qos: .userInitiated) for background transforms.
  • RunLoop.main for UIKit animations that need the .common mode.

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 Sendable friction from reference-heavy publishers.

Keep Combine when:

  • You're consuming an Apple SDK that still vends publishers.
  • @Published + ObservableObject is 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.

Keep looking

Skills are one crate of 325,949. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.