Type-safe notifications and async stream monitoring with Swift 6

6th June, 2025

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.

AsyncMonitor on GitHub

AsyncMonitor on Swift Package Index

NotificationSmuggler requires iOS 17.

NotificationSmuggler on GitHub

NotificationSmuggler on Swift Package Index

∞