iOS Developer. Speaker. Enthusiast. Engineer.

***

Building a type-safe event system in Swift

After wrestling with delegation patterns, NotificationCenter workarounds, and various third-party solutions, I decided it was time to build something from scratch. Something that would embrace Swift's modern concurrency model and provide the type safety I craved. The result is Events — a lightweight Swift package that implements the observer pattern using actors and async/await.

The problem with existing solutions

Before diving into the implementation, let me explain what frustrated me about the existing approaches:

NotificationCenter works, but it's stringly-typed and feels outdated in the age of Swift's type system.

Delegation is great for one-to-one communication, but breaks down when you need to notify multiple observers. You end up with arrays of weak delegates and manual management that's error-prone.

Third-party solutions either brought too much complexity, weren't designed for Swift concurrency, or required significant architectural changes to existing codebases. I was heavily inspired by Signals, but I wanted it to be even more lightweight.

I wanted something that was:

  • Type-safe at compile time
  • Concurrency-safe by design
  • Memory-safe with automatic cleanup
  • Simple to use and understand
  • Lightweight with minimal dependencies

Core design decisions

Swift actors as the foundation

The heart of the Events package is the Event<T> actor. Using an actor was crucial for thread safety — it ensures that all subscription management and event firing happens in a serialized manner, eliminating race conditions.

public actor Event<T: Sendable> {
    private var subscriberStates: [ObjectIdentifier: SubscriberState] = [:]
    private var pendingEventCounts: [ObjectIdentifier: Int] = [:]
    
    public init() { }
    // ...
}

The Sendable constraint on T was essential. It ensures that any data passed through events can safely cross actor boundaries, which is exactly what we need in a concurrent environment.

Weak references for automatic cleanup

To avoid pains with memory management, I've built it such that you you never have to think about unsubscribing, unless you really need to.

I solved it by storing weak references to subscribers:

struct EventSubscriber<T: Sendable> {
    weak var subscriber: AnyObject?
    var handler: EventHandler<T>?
    
    init(subscriber: AnyObject, handler: EventHandler<T>? = nil) {
        self.subscriber = subscriber
        self.handler = handler
    }
}

When an object is deallocated, its weak reference becomes nil, and the event system automatically cleans it up. No manual unsubscription required, though it's still available if you need it.

AsyncStream for ordered event delivery

Initially, I experimented with firing events directly to handler closures, but this created timing issues when events were fired rapidly. Some handlers might not complete before the next event arrived, leading to unpredictable behavior.

The solution was AsyncStream. Each subscriber gets its own stream of events.

This approach guarantees that each subscriber processes events in the exact order they were fired, even if individual handlers take different amounts of time to complete.

The API design philosophy

I spent considerable time on the API design, aiming for something that felt natural and required minimal boilerplate. The basic usage is intentionally simple:

let event = Event<String>()

await event.subscribe(for: self) { value in
    print("Received: \(value)")
}

await event.fire(with: "Hello World!")

Fire vs. FireAndWait

The system is mainly built around a "fire-and-forget" approach, but in some cases it might be helpful to have the possibility to await the handlers to complete, which is why both opportunities are available:

  • fire(with:) - Fires the event and returns immediately (fire-and-forget)
  • fireAndWait(with:) - Fires the event and waits for all handlers to complete

This gives developers the flexibility to choose the right approach based on their use case. Sometimes you want to notify and continue, other times you need to ensure all processing is complete before proceeding.

Void events for parameterless notifications

A common pattern is events that don't need to pass any data — just the fact that something happened. I added convenience methods for Event<Void>:

let logoutEvent = Event<Void>()

await logoutEvent.subscribe(for: self) {
    // No parameters needed
    await handleLogout()
}

await logoutEvent.fire() // No value needed

This feels much more natural than having to pass () everywhere.

Encouraging loose coupling in SwiftUI

One of the most powerful benefits of the Events package becomes apparent when building SwiftUI applications with proper architecture. Events naturally encourage loose coupling between your ViewModels and business logic, making your code more testable and maintainable.

Consider a typical scenario where a ViewModel needs to react to user authentication changes:

import Events

actor UserManagerImplementation: UserManager {
    private(set) var user: User?

    let onLogin = Event<User>()
    let onLogout = Event<Void>()
    
    func login(userName: String, password: String) async throws {
        let user = try await userService.login(username: userName, password: password)
        self.user = user
        
        // Fire the event to notify subscribers
        await onLogin.fire(with: user)
    }

    func logout() async {
        user = nil
        await onLogout.fire()
    }
}

@Observable
final class ViewModel {
    private(set) var user: User?
    private let userManager: UserManager

    init(userManager: UserManager = UserManagerImplementation()) {
        self.userManager = userManager
        Task { await setup() }
    }
    
    func setup() async {
        await userManager.onLogin.subscribe(for: self) { [weak self] user in
            self?.user = user
        }
        
        await userManager.onLogout.subscribe(for: self) { [weak self] in
            self?.user = nil
        }
    }
}

Notice how the ViewModel doesn't need to know how authentication works, when it happens, or what triggers it. It simply reacts to the events. This is loose coupling at its finest.

This architecture becomes even more powerful, if the initialisation of the UserManagerImplementation is moved to a dependency injection framework like Swinject.

Conclusion

For now, I'm happy with the focused, lightweight solution. Sometimes the best tool is the one that does exactly what you need and nothing more.

If you're building Swift applications that need event-driven communication, I'd encourage you to check it out. The API is minimal, the performance is excellent, and the memory management is automatic. What more could you want from an event system?

Tags: