The Evolution of a Swift Macro: Building Swift Streamable Actors
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
awaitchains. 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
AnyKeyPathrequired 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
@Observableneeds@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 countbecomes a computed property, and the data moves to a private_countfield.
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
onTerminationclosure would captureself, creating a strong reference cycle. By making itstatic func countStream(for actor: MyActor), we avoid the need forweak selfand keep the lifecycle clean. - The “Aha” Moment (Private Plumbing): By generating everything within the actor, I made the
registerandremovefunctions private. Callers only see the clean factory method; the “plumbing” is completely hidden.
🗺 The Evolution Roadmap
| Phase | Design Attempt | The “Why” | The “Wall” |
| 1 | Manual Implementation | The original inspiration using didSet. | Boilerplate: Too much work to maintain. |
| 2 | Top-Level ActorStream | Tried a global struct with extensions. | Disconnect: Users couldn’t find the streams. |
| 3 | Internal Streams Struct | Nested a struct inside: actor.streams.count. | Isolation: Bridging isolation was too messy. |
| 4 | KeyPath Registry | A single dictionary of AnyKeyPath. | Actor Isolation: KeyPaths are restricted on actors. |
| 5 | “Clever” Consolidation | Trying to shrink the generated code. | Macro Logic: Repetition is free; type-safety is better. |
| 6 | Shadow Storage Hijack | Moving data to _count + computed properties. | The Breakthrough: Hooked every property update. |
| 7 | Static 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
}