I wrote a first draft of my proposal, please read it and let me know if you have any feedback (I really appreciate anyone who takes the time to read it!) I marked a few non-goals explicitly and changed the scope of the project to focus specifically on reimplementing property wrappers only for stored properties and left local variables and parameters as potential future work. An explicit goal of the project is to ensure that the new macro features are generically applicable and useful even outside of property wrappers, but this can change if it'd be better to focus on property wrappers first and then generalize the implementation for all types of metadata payloads.
Abstract
This project proposes replacing the current ad-hoc implementation of property wrappers for stored properties in nominal declarations with one that utilizes macros. This will remove some specific compiler complexity and create new features that can be used by macro authors to write more powerful transformations. We introduce the concept of a linked macro, which will allow macro writers to inspect nominal declarations and produce metadata for expansion site macros. By implementing this new macro type, we generalize the “producer” and “consumer” nature of property wrappers as a new language tool with property wrappers as the first implementation.
Introduction
Property wrappers are a Swift feature that generalizes common abstractions around access to properties through some mediator. For example, the lazy keyword can be implemented using property wrappers to delay initialization of a variable until it is accessed for the first time. The introduction of property wrappers democratized the ability to direct initialization and access through types that can perform use-case specific logic.
At its core, property wrappers are a syntactic transformation. When a nominal declaration is marked with @propertyWrapper, the compiler introduces the type's name as an attribute in the type’s visibility. When the synthesized attribute is encountered in the program, requests will be made to resolve some information about the property wrapper’s type, dictating how expansion should happen. New backing storage (and potentially projected storage if the property wrapper is API-level) is introduced in the scope of the usage of the attribute, and the declaration with the property wrapper attached is transformed into getters and setters that route to the backing storage.
When property wrappers were proposed in SE-0258 (and further expanded upon in SE-0293), macros were not yet introduced. Macros allow programmers to expand declarations and expressions during compile time. Specifically, this takes place when the compiler encounters a freestanding #foo or a custom @foo attribute; when type checking takes place, macros are expanded before constraint solving. Macros generalize the expansion that takes place when a property wrapper is used, and, with some specific additional features from this proposal, they can completely replace the ad-hoc compiler logic for property wrappers.
Deliverables
Linked Macros
The major addition to the macro system is a new linked macro role for attached macros:
public protocol LinkedMacro: AttachedMacro {
static func expansion(
of node: AttributeSyntax,
providingLinkedMetadataFor declaration: some DeclGroupSyntax,
in context: some MacroExpansionContext
) throws -> LinkedExpansion
}
public struct LinkedExpansion {
public var decls: [DeclSyntax]
public var metadata: LinkedMetadata
}
The linked macro role generalizes the “two macro” behavior of property wrappers: we inspect the property wrapper type and use that information to dictate how expansion happens at usage sites. For linked macros, this metadata is stored in a new representation that is internal to the compiler. The compiler will expose a small set of valid types for metadata properties, and the linked macro can store values of these types that will then be returned upon requests from consumers of the linked macro. These macros can return both new declarations and the linked metadata.
public struct LinkedMetadata {
public func get<T: LinkedMetadataValue>(_ key: LinkedMetadataKey<T>) -> T?
}
public struct LinkedMetadataKey<T: LinkedMetadataValue> {
public let name: String
public init(_ name: String) { self.name = name }
}
public struct LinkedMetadataBuilder {
public mutating func set<T: LinkedMetadataValue>(_ key: LinkedMetadataKey<T>, _ value: T)
public func build() -> LinkedMetadata
}
The linked metadata is type safe, meaning calls to builder.set can be statically verified to ensure that values that are stored to specific keys have the correct type. Additionally, the compiler will mark specific LinkedMetadata key-value pairs as belonging to a specific linked macro when LinkedMetadataBuilder.set is used. Each metadata payload is scoped to a specific linked macro and the identity of the nominal declaration it is attached to. This also allows us to avoid collisions when multiple macro authors use the same name for two different linked metadata keys.
To share a concrete example, for a property wrapper macro, you could have the following API:
public enum PropertyWrapperMetadata {
public enum Keys {
public static let hasWrappedValue =
LinkedMetadataKey<Bool>("hasWrappedValue")
public static let wrappedValueType =
LinkedMetadataKey<TypeHandle>("wrappedValueType")
public static let initWrappedValue =
LinkedMetadataKey<DeclHandle>("initWrappedValue")
// … other keys as needed …
}
}
Then, we could use the linked metadata API like so:
var builder = context.makeLinkedMetadataBuilder()
builder.set(PropertyWrapperMetadata.Keys.hasWrappedValue, true)
builder.set(PropertyWrapperMetadata.Keys.wrappedValueType, wrappedValueTypeHandle)
builder.set(PropertyWrapperMetadata.Keys.initWrappedValue, initDeclHandle)
return LinkedExpansion(
decls: [],
metadata: builder.build()
)
An important part of the API is to ensure that we do not need complex serialization logic and we respect the safety features that come with current macro implementations, like sandboxed plugins. Because of this, we do not want to allow macros to store arbitrary structs within the compiler, and we would like to provide a safe way for macros to give metadata back that still allows for type safety and expressiveness. Therefore, we have the following handles and metadata types:
public protocol LinkedMetadataValue {}
public struct TypeHandle: LinkedMetadataValue, Hashable, Sendable {}
public struct DeclHandle: LinkedMetadataValue, Hashable, Sendable {}
extension Bool: LinkedMetadataValue {}
extension Int: LinkedMetadataValue {}
extension Optional: LinkedMetadataValue where Wrapped: LinkedMetadataValue {}
extension Array: LinkedMetadataValue where Element: LinkedMetadataValue {}
Metadata can store trivial types (like Int, Bool, etc.), but to provide macro authors with an expressive type API, we need to handle more complex types while still allowing for safe interaction between the plugin and the host. These handles are given to the macro writer through an API exposed through context:
extension MacroExpansionContext {
func typeHandle(ofNominalDecl: NominalDeclSyntax) throws -> TypeHandle
func declHandle(ofVarDecl: VariableDeclSyntax, named: String) throws -> DeclHandle
func declHandle(ofInitDecl: InitializerDeclSyntax) throws -> DeclHandle
func typeHandle(ofValueDecl: DeclHandle) throws -> TypeHandle
func typeHandle(from type: TypeSyntax) throws -> TypeHandle
func areTypesEqual(_ lhs: TypeHandle, _ rhs: TypeHandle) -> Bool
}
It is important that these APIs do not expose or influence internal compiler state, as we want to keep this separate from constraint generation. This will not introduce any new macro expansion phase, influence the constraint solver, or affect any solver state. These handles need to be wrappers around the compiler’s internal representation of these types and declarations so consumers can query for the semantics they need. Concretely, these handles cannot be mutated by the macro; they are opaque, read-only views that the compiler exposes safely.
For example, a macro that implements the validation logic for a property wrapper will need to ensure that the wrappedValue label in an initializer matches the type of the property. To do this, we resolve the init syntax and the wrappedValue declaration to a pair of DeclHandles, resolve their types using typeHandle(ofValueDecl:), and check for their equality using areTypesEqual. This allows us to use type equality logic from the compiler without needing to implement it on our own. Additionally, we can now send compiler metadata back from our plugin in a way that the compiler can safely store. We only allow trivial types and these handles (along with optionals and arrays) to be stored in metadata.
When the linked macro logic happens, the macro will return the metadata back to the compiler, which will then store this metadata to be used in request evaluator queries that are keyed by the linked macro identity and attached nominal declaration identity. The macro writer can then use these metadata structs during expansion. We will expose an API on the context parameter so the consumers of these linked macros can ask for the metadata:
extension MacroExpansionContext {
func linkedMetadata(for attribute: AttributeSyntax) -> LinkedMetadata?
}
This function will resolve the attribute to the linked producer macro and attached declaration that our current expansion corresponds to. When the compiler sends this linked metadata to the plugin, it will convert its own internal representation of the metadata into a Swift LinkedMetadata struct.
Tying back to property wrappers, the usage and consumer macros will be declared as expected:
@attached(linked, to: PropertyWrapperUsageSite)
macro propertyWrapper() = #externalMacro(module: “PropertyWrapperImpl”, type: “PropertyWrapperTypeMacro”)
@attached(peer)
// … and other roles, as needed …
macro propertyWrapperUsageSite() = #externalMacro(module: “PropertyWrapperImpl”, type: “PropertyWrapperUsageSiteMacro”)
When the macros are first registered, the compiler will note that @propertyWrapper links to @propertyWrapperUsageSite. When it finds @Foo, it will resolve the attribute to struct Foo marked with @propertyWrapper. It will delegate to the plugin that expands PropertyWrapperTypeMacro and produce its LinkedMetadata, which will be sent back to the compiler to store in an internal key-value structure. Then, it will delegate to the PropertyWrapperUsageSiteMacro to perform the correct expansion, exposing the correct LinkedMetadata through context APIs.
SIL Name Backwards Compatibility
Property wrappers affect how declarations are lowered to SIL and the names they are eventually given. We will use tests and inspect SIL outputs to ensure that we are properly lowering our property wrappers to the correct name format and our new SIL output remains compatible with what is currently expected.
Property Wrapper Re-implementation for Type Properties
With the above changes to the macro system, we should be able to reimplement property wrappers for type properties. This is a good place to begin the reimplementation process, as properties require type annotations, meaning we wouldn’t need to use heavy type inference logic in order to properly expand property wrapper usage sites. By only worrying about this single usage case, we can begin to incrementally replace parts of the ad-hoc logic without completely moving over to a full macro reimplementation.
To make the plan concrete, this project will only focus on re-implementation for stored type properties, not local variables or parameters. These other usage sites require extremely complex compiler infrastructure changes that will be explored below.
Tests
A full testing suite needs to be produced to ensure that property wrappers are compiled the exact same way they currently are. We will introduce more tests for each of the current property wrapper usage locations: parameters, local declarations, and properties. Although we are only reimplementing property wrappers for one specific use, it is important to document current expected behavior across all usage sites for potential future continuation of reimplementation. We will also test that facilitation between linking macros is handled correctly by the compiler, as this will be a new feature that macros are currently not capable of. Finally, we will have backwards compatibility tests that ensure that the current SIL naming scheme is honored with our new implementation.
Documentation
This project will add novel infrastructure to the compiler to accommodate for new macro features, so it will be important for documentation to show how these features are used. It is important for possible future use cases and improvements of these new macro features that compiler authors know how they are implemented.
Possible Challenges
Property wrappers are heavily integrated with Swift and see heavy usage in important APIs like SwiftUI. Therefore, it is important that changes happen incrementally and are heavily tested to ensure feature parity and correctness. Additionally, property wrappers span across the entire compiler pipeline; ensuring that all current features are accounted for in our new implementation prevents later possible complications.
Non-Goals
Function and Closure Parameter Macros
Currently, attached macros are not supported on function or closure parameters. We would need to introduce a new attached macro role, ParameterMacro, that would allow users to attach attributes to function and closure parameters and add to the body of the function of which they are a parameter of. Additionally, these macros will need to be able to dictate syntactic transformations that take place at the call site. Using an example from SE-0293:
struct History<Value> { ... }
@propertyWrapper
struct Traceable<Value> {
init(wrappedValue value: Value)
init(projectedValue: History<Value>)
var wrappedValue: Value
var projectedValue: History<Value>
}
when we call this function with property wrapped parameters:
func log<Value>(@Traceable value: Value) { ... }
let history: History<Int> = ...
log(value: 10)
log($value: history)
we need to inspect the label of the parameter and inject newly synthesized arguments into the call, as below:
log(value: Traceable(wrappedValue: 10))
log(value: Traceable(projectedValue: history))
This new ParameterMacro role would have two expansion functions: one for the declaration site (where we attach the property wrapper on the parameter), and one for the call site. The macro author would be able to inspect the type of the parameter to indicate which context they are in, and perform the necessary logic: for example, the above argument synthesization at the call site only happens for functions; closures must indicate whether they take in a projected value or a wrapped value in the closure signature and/or the label name (by prepending a $).
Implementing function and closure macros would change how function calls are currently processed and type checked. If we allow macros to expand both within the function body and at call sites, we now need to have the compiler check for any necessary expansion for most/all function calls. This is a massive infrastructure change, and this feature for macros could be its own project. Therefore, property wrapper reimplementation for parameters should be a future goal.
Constraint Generation API
Property wrappers are heavily integrated with the type system, with type information flowing in and out of the expansion sites. When the constraint solver is attempting to resolve types, it will query the nominal declaration and use the types of synthesized local declarations (like the projected value and wrapped backing storage) in its attempts. If we were to naively expand property wrappers using our current macro capabilities, we would be unable to resolve types as we’d have no information flowing back out of property wrappers.
On expansion, we need to provide a way for macros to inform the compiler of any new constraints it can deduce from their own internal semantics. However, this is a massive change that would require careful auditing of the type checker flow and how property wrappers currently influence generation and solving of constraints. This feature would change very high traffic parts of the compilation pipeline and could be a project in and of itself. It would be best to mark this as a non-goal for this project and work on it in the future. Additionally, it would be best to move the reimplementation of type inference heavy property wrapper usages, like local variables and parameters, to a later project.
Timeline
Community Bonding (May 1 – May 24)
- Finalize API design for
LinkedMacro, LinkedMetadata system, and TypeHandle/DeclHandle with mentor
- Identify request evaluator integration points, caching, and invalidation strategy (nominal declaration changes)
- Define feature flag for macro-based property wrapper path, allowing for incremental transition
- Audit existing property wrapper tests and categorize by usage site, producing tests for areas that are currently lacking (if necessary)
- Draft linked macro and metadata test files
- Produce reviewed technical design document and plan for testing
Week 1 (May 25 – May 31)
- Implement
@attached(linked, to: ...) role parsing and registration
- Represent producer to consumer link relationships in macro registry
- Add failing tests for linked macro registration and resolution
- Implement basic diagnostics for linked macros
Week 2 (June 1 – June 7)
- Implement
LinkedMetadata, LinkedMetadataKey<T>, and LinkedMetadataBuilder
- Implement compiler-side metadata storage and request-evaluator caching
- Write metadata round-trip tests (producer sets, consumer retrieves)
- Add tests for allowed metadata types and invalid type usage
Week 3 (June 8 – June 14)
- Implement
context.linkedMetadata(for:) API
- Implement conversion between internal metadata and plugin representation
- Write tests for multiple linked producers and metadata visibility
- Add diagnostics tests for missing or invalid metadata
Week 4 (June 15 – June 21)
- Implement
TypeHandle and DeclHandle types
- Add context APIs for obtaining handles from syntax and declarations
- Write tests validating handle identity stability and immutability
- Add negative tests for misuse
Week 5 (June 22 – June 28)
- Implement
areTypesEqual using canonical type comparison
- Ensure no solver mutation or new constraint phases are introduced
- Write equality tests covering generics and cross-module cases
- Add basic stress tests for repeated equality queries (ensure that no solver state is mutated)
Week 6 (June 29 – July 5)
- Add AST dump comparison tests
- Add SIL comparison tests for stored properties
- Ensure all parity tests pass under legacy implementation with feature flag off
Midterm Evaluation (July 6 – July 10)
LinkedMacro infrastructure complete
LinkedMetadata system complete
TypeHandle/DeclHandle API complete
- Parity test suite written and passing under legacy path
- Feature flag integrated into the compiler
Week 7 (July 6 – July 12)
- Implement
PropertyWrapperTypeMacro producer.
- Extract
wrappedValue and init(wrappedValue:) metadata
- Populate
LinkedMetadata for property wrapper types
- Add diagnostics tests for malformed wrappers
Week 8 (July 13 – July 19)
- Implement
PropertyWrapperUsageSiteMacro for stored properties
- Expand backing storage, getter, and setter
- Enable macro path under feature flag
- Fix failing parity tests incrementally
Week 9 (July 20 – July 26)
- Ensure SIL name and mangling parity with legacy implementation
- Add SIL regression tests
- Validate behavior for access control and generic wrappers
Week 10 (July 27 – August 2)
- Validate request evaluator invalidation behavior
- Stress test metadata caching
- Run full Swift test suite and fix regressions
Week 11 (August 3 – August 9)
- Add cross-module wrapper tests
- Validate incremental compilation behavior
Week 12 (August 10 – August 16)
- Write documentation for
LinkedMacro architecture and metadata lifecycle
- Final full test suite run
- Remove or gate legacy stored-property wrapper path
- Prepare final PRs for submission
Final Submission (August 17 – August 24)
LinkedMacro role merged
LinkedMetadata system merged
TypeHandle/DeclHandle API merged
- Stored-property wrapper reimplementation merged
- Full regression and parity test suite committed
- Documentation complete