Type-safe notifications in Swift

I’ve been coding exclusively in Swift for the past 2 years, and I’ve really been enjoying it. So much of my career has been in type-ambiguous languages – Javascript, PHP, or Objective-C. There’s some typing there, but the compiler doesn’t enforce too much. Swift, on the other hand, has brought me back to my Rice days, where so much of the education in Java and Scheme was built on functional programming and strong typing.

The number one lesson I took away from that time is that casting to types breaks the contract that strong types have with the compiler. In Swift, I can write:

let foo = bar as? SomeType

If bar is a SomeType, then it will be assigned to foo, but otherwise nil will be set. This is often used to short circuit certain code paths that require certain types, like the following:

guard let foo = bar as? SomeType else { return }

What this line does is enforce that bar is a SomeType and is assigned to foo, and otherwise will return from the function. So we know that foo can never be nil after this line.

This happen quite a bit when implementing Notifications in Swift, a feature largely unchanged from Objective-C. In that world, a notification can be broadcast throughout the app with a String name, and an arbitrary [String: Any] dictionary of additional information, called the notification’s userInfo.

Posting a notification in Swift looks like:

let myKey: Int = 10
let otherStuff: CGFloat = 20

NotificationCenter.default.post(name: .MyNotificationName, userInfo: ["someItem": myKey, "otherThing": otherStuff)

The above code sends a notification with the MyNotificationName along with a dictionary of two values. Observing a notification in Swift generally follows this boilerplate:

NotificationCenter.default.addObserver(self, selector: #selector(didNotify), name: .MyNotificationName object: nil)

...


@objc func didNotify(_ notification: Notification) {
    guard
        let myKey = notification.userInfo?["someItem"] as? Int,
        let otherStuff = notification.userInfo?["otherThing"] as? CGFloat
    else {
        // The notification with the name `MyNotificationName` was sent, but I wasn't able to get the information out of the dictionary...?
        return
    }
    ...
    // do stuff with myKey and otherStuff
}

The above shows the problem sending notification information through a weakly typed dictionary and relying on casting to pull it out. It’s not uncommon to send the same type of notification from multiple places in the same app, and as the program grows, some parameters of a notification may change or get added. It’s easy for the key names used to encode the information in a dictionary to have typos unless you use constants, and even then the wrong type of variable may inadvertently get sent – maybe a Float instead of CGFloat – and then the observer on the other end fails silently.

So there’s a few problems with this method:

  • There’s no compiler support to help me catch when I make a mistake or a typo in a key name
  • There’s nothing to stop me sending the wrong type of variable which will also fail to decode
  • The observer silently fails at runtime, instead of loudly failing at compile-time

To help send strongly typed information along with a notification, I’ve added the following extension and Enum to my projects:

extension NotificationCenter {
    func post(name: NSNotification.Name, object: Any? = nil, info: UserInfo) {
        post(name: name, object: object, userInfo: [Notification.InfoKey: info])
    }
}

extension Notification {
    fileprivate static let InfoKey = "InfoKey"

    var info: UserInfo? {
        return userInfo?[Self.InfoKey] as? UserInfo
    }
}

enum UserInfo {
    case example(_ some: Int, other: CGFloat)
    case bumble(_ stuff: CGFloat, identifier: String)
    case foo(_ bar: Float, baz: Double)
}

...

// And now, sending the notification above looks like:
NotificationCenter.default.post(name: .MyNotificationName, info: .example(myKey, otherStuff))

This lets me define packages of information that can be sent along with a notification and then decoded in a type-safe way on the observer’s side. There’s only a limited number of possible options that I can send, and these will be compile-time checked for correctness and type-safety, and since I’m sending a UserInfo enum, the compiler forces me to choose one of those three options and will raise an error if any of its parameters are of the wrong type.

On the receiving end, I do the following to decode them in a type-safe way:

@objc func didNotify(_ notification: Notification) {
    guard
        case .some(.example(let someItem, let otherThing)) = notification.info
    else {
        assertionFailure("could not decode \(notification.info)")
        return
    }

    // someItem and otherThing are strongly typed all the way through, enforced by the compiler
}

Adding an assert on the receiving end helps protect against sending the wrong/no enum at all with a notification. I’m really happy with this solution. It doesn’t involve much extra code to get working, and adding a new UserInfo enum case is extremely easy as new parameters are needed.

A similar idea has been pursued by Joe Fabisevich with his TypedNotifications library. In that version, each notification is its own type, and an associatedType defines the userInfo that is attached. This lets both the notification name and its user info be married into a single type, which makes it impossible to send a mismatched userInfo with any particular notification name, very cleverly done.

// define the notification and payload in the same place

struct TypedPersonNotification: TypedPayloadNotification {
    let payload: Person
}

...

// send the notification

let amanda = Person(name: "Amanda", job: .softwareDeveloper)
let amandaNotification = TypedPersonNotification(payload: amanda)
NotificationCenter.default.post(typedNotification: amandaNotification)

...

// and observe the notification

NotificationCenter.default.register(type: TypedPersonNotification.self, observer: self, selector: #selector(personNotificationWasReceived))

@objc func personNotificationWasReceived(notification: Notification) {
    guard
        let person = notification.getPayload(notificationType: TypedPersonNotification.self)
    else {
        assertionFailure("could not decode \(notification.name)")
        return
    }
    ...
    // continue processing
}

I really like that the notification name and its payload are linked through the type, which adds that additional compiler check that my enum solution doesn’t have. I think the only missing piece for both our options is matching the type of the observing function. If there was a way to enforce the #selector() matched the notification type when adding the observer, I think that would be the holy grail.

Leave a Reply

Your email address will not be published.