I’ve been working on a type-safe notification system for Swift called PonyExpress. The Foundation framework gives us NotificationCenter, but unfortunately we can only send [String: Any]
along with the notification. Consider the following swift playground:
import Foundation public extension NSNotification.Name { static let MyCustomNotification = Notification.Name("MyCustomNotification") } struct MyData { let data: Int let other: Float } class SomeObserver { init() { NotificationCenter.default.addObserver(self, selector: #selector(didHearNotification), name: .MyCustomNotification, object: nil) } @objc func didHearNotification(_ notification: Notification) { guard let myData = notification.userInfo?["myData"] as? MyData else { fatalError("uh oh, didn't get my notification") return } print("\(myData)") } } let observer = SomeObserver() NotificationCenter.default.post(name: .MyCustomNotification, object: nil, userInfo: ["myData": MyCustomNotification(data: 12, other: 9)])
There’s a few issues with this code:
- There’s no guarantee that a posted
MyCustomNotification
will always include aMyData
object. - The key
"myData"
in theuserInfo
is ripe for copy/paste errors, and has no compiler error or warning if mistyped. - Uses legacy Objective-C
#selectors
, which don’t even give compiler errors for mis-typing thenotification
parameter. - It’s all very verbose, which obfuscates what’s really going on.
Instead, my goal with PonyExpress
is to give strong typing to notifications in Swift, both when posting a notification and when observing a notification.
First, Instead of sending both a String
notification name and a separate payload, simply send a single object as the notification. The notification is the payload.
Next, allow the programmer to create their own NotificationCenters (Called PostOffice
in PonyExpress
) and include a generic type to limit the types of notifications that can be sent.
protocol Mail { } struct Letter: Mail { } struct Box: Mail { } struct PostCard: Mail { } let postOffice = PostOffice<Mail> postOffice.post(Letter()) // ok postOffice.post(Box()) // ok postOffice.post(SomethingElse()) // compiler error
This can help prevent copy/paste errors when setting up and sending notifications. It’s using the type system help catch simple logic errors.
What’s even better, is that observers can register a strongly typed observation block (or method):
class SomeObserver { init() { postOffice.register(self, SomeObserver.didHearNotification) } func didHearNotification(_ letter: Letter) { print("\(letter)") } }
The observer above registers its method, which automatically limits the type of notification that it hears from the PostOffice
. The registration line 3 is type checked that the method accepts a subtype of Mail
. Then the PostOffice
filters all sent notifications and calls the observer method with any Letters
that get sent.
It’s now impossible for a sent notification to fail because of mismatched string keys in a userInfo
, or because the payload changed shape, or copy/paste error, etc. It’s strongly typed both sending notifications and receiving them.
Great! So what’s the problem?
Well, it turns out that we can either constrain the notification types that can be posted through a PostOffice
, or we can constrain the type of the notification in the observer’s method, but not both.
To help make the magic work, we’ll define an AnyRecipient
type to wrap the type of the observer:
// This doesn't need to be exposed outside of PonyExpress, it's just for internal bookkeeping. internal struct AnyRecipient { var block: ((Any) -> Void) init<U>(_ block: @escaping (_ notification: U) -> Void) { self.block = { notification in guard let notification = notification as? U else { return } block(notification) } } }
The following example code will create a PostOffice
that allows for constraining the notification type, but doesn’t allow for constraining the observer’s method. Here we’ll use blocks instead of methods as the code is a little easier to read:
class CompileCheckedExample<Generic1> { var recipients: [AnyRecipient] = [] // error: type 'Generic2' constrained to non-protocol, non-class type 'Generic1' func register<Generic2: Generic1>(block: @escaping (Generic2) -> Void) { recipients.append(AnyRecipient(block)) } func post(notification: Generic1) { recipients.forEach({ $0.block(notification) }) } } let example = RuntimeCheckedExample<Mail>() example.register(block: { (_ letter: Letter) in print("received: \(letter)") })
Unfortunately, we’re not able to define a generic type that constrains another generic type. We can hardcode the Generic1
type, but that of course makes this cumbersome for a programmer to quickly implement.
class SingleCheckedExample { var recipients: [AnyRecipient] = [] // This works, but requires any type besides `Mail` to be reimplemented in another class func register<Generic2: Mail>(block: @escaping (Generic2) -> Void) { recipients.append(AnyRecipient(block)) } func post(notification: Mail) { recipients.forEach({ $0.block(notification) }) } } let example = SingleCheckedExample() example.register(block: { (_ letter: Letter) in print("received: \(letter)") })
If we can’t check types at compile time, maybe we can check types at runtime? Unfortunately not here either:
class RuntimeCheckedExample<Generic1> { var recipients: [AnyRecipient] = [] func register<Generic2>(block: @escaping (Generic2) -> Void) { // every pair of types expect Generic1 == Generic2 will fail, even if Generic2 implements Generic1 guard Generic2.self is Generic1.Type else { fatalError("\(Generic1.self) is not a \(Generic2.self)") } recipients.append(AnyRecipient(block)) } func post(notification: Generic1) { recipients.forEach({ $0.block(notification) }) } }
Our only option is to either constraint the type of notification that’s sent, dramatically limiting the type of the observer that can be registered:
class ConstrainNotifierExample<Generic1> { var recipients: [AnyRecipient] = [] func register(block: @escaping (Generic1) -> Void) { recipients.append(AnyRecipient(block)) } func post(notification: Generic1) { recipients.forEach({ $0.block(notification) }) } } let example = ConstrainNotifierExample<Mail>() example.register(block: { (_ mail: Mail) in print("received: \(mail)") }) // ok example.register(block: { (_ letter: Letter) in print("received: \(letter)") }) // compiler error
Or we can have expressive observers but require any object to be sent as a notification:
class ConstrainObserverExample { var recipients: [AnyRecipient] = [] func register<Generic1>(block: @escaping (Generic1) -> Void) { recipients.append(AnyRecipient(block)) } func post(notification: Any) { recipients.forEach({ $0.block(notification) }) } } let example = ConstrainObserverExample() example.register(block: { (_ mail: Mail) in print("received: \(mail)") }) example.register(block: { (_ letter: Letter) in print("received: \(letter)") }) example.post(notification: Letter()) // Unfortunately, also able to post notifications of any type example.post(notification: FakeMail())
It’s unfortunate that we can’t constrain a generic type based on another generic type.
For our purposes, I think the best compromise is to use SingleCheckedExample
above, and provide a base Mail
type that must be implemented for all posted notifications. As I continue to work on PonyExpress
, I think that this is the direction I’ll be going.
This prevents most objects from being accidentally sent as notifications, and postOffice.post(someObject)
will show a compiler error for most typo mistakes. And it also allows the observer to constrain the type of notification that it receives, which prevents copy/paste logic errors when duplicating guard
statements at the top of notification methods.
Overall, we’ve gotten to an acceptable answer, but it leaves me wanting for a just a little bit more from Swift’s type system.