Combine to async await
Skill almasumdev/awesome-ios-agent-skills/.github/skills/migration/combine-to-async-await
Expert guidance on migrating Combine publishers to Swift structured concurrency (async/await, AsyncSequence). Use when replacing Combine or adapting third-party APIs.From its SKILL.md
npx -y skills add almasumdev/awesome-ios-agent-skills --skill combine-to-async-awaitAssembled 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.5 KB, ~1.2k tokens by cl100k_base, as published. Nobody here has run it
Combine → async/await Migration
Instructions
Swift structured concurrency is now the preferred concurrency model. Combine remains useful at framework boundaries (@Published, NotificationCenter.publisher, Apple SDKs that still return publishers), but new code should favor async/await and AsyncSequence.
1. Mental Mapping
| Combine | async/await equivalent |
|---|---|
Future<Output, Error> | async throws -> Output |
AnyPublisher<Output, Error> | AsyncThrowingStream<Output, Error> |
AnyPublisher<Output, Never> | AsyncStream<Output> |
PassthroughSubject | AsyncStream.makeStream() continuation |
CurrentValueSubject | @Observable property + stream |
sink { } subscription | for await value in sequence { } |
.map, .filter | .map, .filter on AsyncSequence |
.combineLatest, .zip | async let pairs, withTaskGroup |
.flatMap | nested await |
Cancellation via AnyCancellable | Task cancellation + Task.isCancelled |
2. Single-Value Publishers → async throws
Before:
func fetchUser(id: String) -> AnyPublisher<User, Error> {
URLSession.shared.dataTaskPublisher(for: url(id))
.map(\.data)
.decode(type: User.self, decoder: JSONDecoder())
.eraseToAnyPublisher()
}
After:
func fetchUser(id: String) async throws -> User {
let (data, response) = try await URLSession.shared.data(from: url(id))
try HTTPError.ensureOK(response)
return try JSONDecoder().decode(User.self, from: data)
}
3. Stream Publishers → AsyncSequence
Use .values on any Publisher:
for try await article in articlesPublisher.values {
handle(article)
}
For NotificationCenter:
for await note in NotificationCenter.default.notifications(named: UIApplication.didBecomeActiveNotification) {
refresh()
}
4. Adapting Callback APIs
Bridge with withCheckedThrowingContinuation:
func loadImage(named name: String) async throws -> UIImage {
try await withCheckedThrowingContinuation { cont in
ImageLoader.shared.load(name) { result in
switch result {
case .success(let image): cont.resume(returning: image)
case .failure(let error): cont.resume(throwing: error)
}
}
}
}
Pair with withTaskCancellationHandler for cancellable APIs:
func download(_ url: URL) async throws -> Data {
let handle = Downloader()
return try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { cont in
handle.start(url) { cont.resume(with: $0) }
}
} onCancel: {
handle.cancel()
}
}
5. Replacing Subjects
PassthroughSubject:
let (stream, continuation) = AsyncStream<Event>.makeStream()
// emit:
continuation.yield(.tapped)
// consume:
for await event in stream { handle(event) }
// finish:
continuation.finish()
CurrentValueSubject → an @Observable property plus an AsyncStream for external observers.
6. Replacing combineLatest / zip
async let user = fetchUser(id: id)
async let settings = fetchSettings(id: id)
let (u, s) = try await (user, settings)
Or with a TaskGroup for a dynamic set of requests:
try await withThrowingTaskGroup(of: Article.self) { group in
for id in ids { group.addTask { try await fetchArticle(id: id) } }
var out: [Article] = []
for try await article in group { out.append(article) }
return out
}
7. Cancellation
Combine cancels when the subscription is released. async/await cancels when the owning Task cancels. In SwiftUI, attach work to a view with .task:
.task(id: userID) {
do { try await model.load(userID) }
catch is CancellationError { /* ignore */ }
catch { model.error = error }
}
8. When to Keep Combine
@Publishedis still convenient inside legacyObservableObjectmodels — keep it until you migrate to@Observable.- Apple SDKs (e.g., some
StoreKit,CoreLocationadapters) still return publishers. Convert with.valuesat the call site rather than rewriting upstream. - Complex operator chains on finite sources — migrate deliberately, not for its own sake.
9. Migration Steps
- Identify publishers with a single value → rewrite as
async throws. - Identify long-running publishers → expose as
AsyncSequence. - Replace
sinksites withfor awaitloops insideTasks. - Kill
Set<AnyCancellable>storage; move to structuredTaskownership. - Delete Combine imports when no references remain.
Checklist
- New APIs return
async throwsorAsyncSequence, not publishers. - Callback APIs are bridged with
withCheckedContinuation, cancellation handled. - No
Set<AnyCancellable>fields in new view models. -
combineLatestis replaced withasync letorTaskGroup. - Remaining Combine use is scoped to SDK boundaries and documented.
What ships with it
Read from the repository
Just SKILL.md. No reference files, no scripts.