Type-safe notifications and async stream monitoring with Swift 6
∞Swift 6 concurrency checking made handling notifications without warnings kinda tedious. The old Combine approach doesn't work with @Sendable
closures and manually managing tasks gets repetitive. I made a couple of tiny Swift packages to help out with the situation: AsyncMonitor which wraps task management, and NotificationSmuggler which adds a type-safe interface on top of Notification
and NotificationCenter
.
The manual approach
Here's what I was writing over and over:
// Causes Sendable warnings in Swift 6
let task = Task { [weak self] in
for await notification in NotificationCenter.default.notifications(named: .NSCalendarDayChanged) {
guard let self else { return }
await self.handleDayChange()
}
}
// Store it somewhere, hope you remember to cancel it...
And when you have a bunch of these then you wind up with lots of properties to track them and other boilerplate.
AsyncMonitor
AsyncMonitor wraps the task lifecycle. Instead of managing tasks manually you use streams like Combine publishers:
import AsyncMonitor
let cancellable = NotificationCenter.default
.notifications(named: .NSCalendarDayChanged)
.map(\.name)
.monitor { _ in
print("The date is now \(Date.now)")
}
The monitor
method creates a Task
internally and handles cleanup when the cancellable goes out of scope. The closure is async so you can await in it.
You're free to do the usual [weak self]
and guard let self else { return }
dance, but there's a variant that accepts a context parameter that's automatically weakified. Your closure receives a strong reference:
let cancellable = NotificationCenter.default
.notifications(named: .NSCalendarDayChanged)
.map(\.name)
.monitor(context: self) { _self, _ in
_self.dayChanged()
}
The monitor finishes automatically after finding a nil context.
Cancellation tokens
Similar to Combine there's the concept of a cancellable that ties the observation to the lifetime of that cancellable instance. And of course, we call it a dispose bag. Just kidding obviously, it's called AsyncCancellable
and there's an AnyAsyncCancellable
type-erasing wrapper. So very much like Combine you write this:
import AsyncMonitor
class Whatever {
private var cancellables: Set<AnyAsyncCancellable> = []
func something() {
NotificationCenter.default
.notifications(named: .NSCalendarDayChanged)
.map(\.name)
.monitor { _ in /* ... */ }
.store(in: &cancellables)
}
}
KVO
Sometimes you need KVO and the old ways are best. There's a KVO extension that bridges to async sequences:
// AVPlayer's Combine publisher for rate doesn't publish all the values
player.monitorValues(for: \.rate) { rate in
print("Player rate: \(rate)")
}.store(in: &cancellables)
What about Combine?
Combine works but doesn't mesh well with Swift 6 concurrency. The @Sendable
requirements make it annoying. You can write the Task code manually but it gets repetitive when you have a lot of observers.
NotificationSmuggler
NotificationSmuggler solves a different problem: type-safe notifications. No more dumpster diving in userInfo
.
Define your contraband:
import NotificationSmuggler
struct ProjectExportComplete: Smuggled, Sendable {
let projectID: String
let outputURL: URL
}
The Smuggled
protocol generates a unique notification name and userInfo key from your type name.
Smuggle your illicit goods like so:
NotificationCenter.default.smuggle(ProjectExportComplete(projectID: "project-123", outputURL: exportURL))
// which is short for
NotificationCenter.default.post(.smuggle(ProjectExportComplete(projectID: "project-123", outputURL: exportURL)))
And on the other side it's as easy as any other notification using an extension on NotificationCenter
:
for await notification in NotificationCenter.default.notifications(for: ProjectExportComplete.self) {
print("Project \(notification.projectID) exported to \(notification.outputURL)")
}
Using them together
They work well together. Here's a more full example:
import AsyncMonitor
import NotificationSmuggler
struct BackupCompleteNotification: Smuggled, Sendable {
let success: Bool
let totalSize: Int64
}
NotificationCenter.default
.notifications(for: BackupCompleteNotification.self)
.monitor(context: self) { _self, notification in
_self.updateBackupStatus(success: notification.success)
}.store(in: &cancellables)
// elsewhere
NotificationCenter.default.smuggle(BackupCompleteNotification(success: true, totalSize: 42))
That's it
Both libraries are small and focused. AsyncMonitor is about 100 lines, NotificationSmuggler is smaller. Zero dependencies.
AsyncMonitor requires iOS 17. It supports both iOS 17 and iOS 18 with different initializers due to changes in inheriting actor isolation.
NotificationSmuggler requires iOS 17.