The Evolution of a Swift Macro: Building Swift Streamable Actors

Published by malhal on

Building a library is rarely a straight line. It’s a series of “What if?” moments followed by “Oh, that’s why…” realisations.

When I set out to build Swift Streamable Actors, the goal was simple: make Actor state observable via AsyncStream without the boilerplate. But getting there required navigating the strict world of Swift Concurrency and Macro expansion order.

Here is the “behind-the-scenes” story of how the API evolved into the 1.0 winner.


Phase 1: The Manual Implementation (The Inspiration)

Every project starts with a pain point. To make an actor property observable, I was manually writing the same 30 lines of code over and over: a dictionary of observers, a didSet wrapper, and registration methods.

Swift

// The "Inspiration" - Robust but exhausting to write for every property
actor MyActor {
    private var countObservers: [UUID: AsyncStream<Int>.Continuation] = [:]
    var count: Int = 0 {
        didSet { countObservers.values.forEach { $0.yield(count) } }
    }
    // ... plus registration, unregistration, and stream factory logic ...
}

Phase 2: The Top-Level “ActorStream” Extension

My first macro instinct was to keep the Actor’s namespace completely “pure.” I tried a global ActorStream struct where the macro would generate extensions like ActorStream.myActor.count(for: instance).

  • The Lesson: It felt disconnected. Users shouldn’t have to look into a global “junk drawer” to find streams for a specific actor. I realised the API should live where the data lives.

Phase 3: The Internal “Streams” Struct

Next, I tried nesting the complexity. The macro would generate an internal struct Streams inside the actor to allow for a call site like actor.streams.count.

  • The “Aha” Moment: I realised we had to stay in the Actor context. Nested structs are separate types; trying to bridge the isolation between the actor and a nested struct created messy await chains. The observation logic had to be an integral part of the actor itself to maintain the correct serial execution context.

Phase 4: The KeyPath & “Cleverness” Trap

I attempted to use Swift KeyPaths to create a unified observation engine using a single dictionary: private var observers: [AnyKeyPath: [UUID: Continuation]].

  • The Fail: Actor isolation prevents KeyPaths from being used as flexible accessors across boundaries.
  • The “Aha” Moment: I also realised that using a single dictionary with AnyKeyPath required constant type-casting (type erasure). Since the macro was generating the code anyway, there was no need to “shrink” the implementation. I realised that repetition is free with macros. Explicit, type-safe dictionaries for every property performed better than one “clever” type-erased dictionary.

Phase 5: The “Clever” Consolidation vs. Performance

In this phase, I debated between writing a generic observer handler or specific ones.

  • The Lesson: I decided that “Explicit is better than Clever.” By generating dedicated storage and notification logic for every single property, the macro ensures the most performant, direct path from a variable change to the stream yield, without any dictionary lookups or type casting.

Phase 6: The Shadow Storage Hijack

To make the properties feel like native Swift variables, I moved to the Shadow Storage pattern—the same logic used by Apple’s @Observable.

  • The “Aha” Moment: I wondered why Apple’s @Observable needs @ObservationIgnored. I realised it’s because a Member Macro on the actor level has to decide the architecture for all properties at once during the first pass. It has to “take over” the properties immediately to ensure the flow is consistent.
  • The Win: The macro “hijacks” your variable: var count becomes a computed property, and the data moves to a private _count field.

Phase 7: The Static Factory (The 1.0 Winner 🏆)

The final polish was about privacy and avoiding the most common trap in Swift: memory leaks.

  • The “Aha” Moment (The Leak Fix): I realised the stream factory must be static. If it were an instance method, the onTermination closure would capture self, creating a strong reference cycle. By making it static func countStream(for actor: MyActor), we avoid the need for weak self and keep the lifecycle clean.
  • The “Aha” Moment (Private Plumbing): By generating everything within the actor, I made the register and remove functions private. Callers only see the clean factory method; the “plumbing” is completely hidden.

🗺 The Evolution Roadmap

PhaseDesign AttemptThe “Why”The “Wall”
1Manual ImplementationThe original inspiration using didSet.Boilerplate: Too much work to maintain.
2Top-Level ActorStreamTried a global struct with extensions.Disconnect: Users couldn’t find the streams.
3Internal Streams StructNested a struct inside: actor.streams.count.Isolation: Bridging isolation was too messy.
4KeyPath RegistryA single dictionary of AnyKeyPath.Actor Isolation: KeyPaths are restricted on actors.
5“Clever” ConsolidationTrying to shrink the generated code.Macro Logic: Repetition is free; type-safety is better.
6Shadow Storage HijackMoving data to _count + computed properties.The Breakthrough: Hooked every property update.
7Static Factory (1.0)ActorName.countStream(for: instance).The Winner: Leak-proof, fast, and discoverable.

Final Philosophy: Explicit over Clever

The biggest takeaway? Macros change the rules of API design. Usually, we write generic code to avoid repetition. But with macros, we can generate the most performant, explicit, and private code possible. 1.0 isn’t just a boilerplate saver—it’s a safer, more “Swiftic” way to handle Actor state.

Check out the GitHub Repo and start streaming your Actor state with a single line of code.

@Streamable
actor Island {
    var count = 0
}