The proposal for property wrappers offers only this for explaining how they are intended to interact with willSet and didSet:
Those properties can have observing accessors (
willSet/didSet), but not explicitly-written getters or setters.
But since property wrappers and meant to blur the line between a public facing stored property and an underlying, hidden wrapper type, things get very murky with willSet/didSet.
Consider the following very simple property wrapper:
@propertyWrapper
struct Wrap<Value> {
var wrappedValue: Value
init(wrappedValue: Value) {
self.wrappedValue = wrappedValue
}
}
If one were to use it with didSet like so:
@Wrap var value = 0 {
didSet { print("didSet") }
}
…then the didSet is called as expected. You can see this with the following test:
import Testing
@Test func accessors() async throws {
@Wrap var count = 0 {
didSet { Issue.record() }
}
withKnownIssue {
count = 1 // ✅ Passes
}
}
That proves that the didSet is called when mutating the count variable directly.
But there are other ways to mutate count. You can also go through _count.wrappedValue, and doing so does not trigger didSet:
withKnownIssue { // ❌ Fails
_count.wrappedValue = 1
}
It seems reasonable to me that any mutation of wrappedValue would trigger the observers on count.
This discrepancy in behavior becomes more problematic when dealing with projected values that may have mutating functions:
$count.compute()
If that compute method mutates the wrappedValue it will not trigger didSet.
This problem also affects SwiftUI, in which bindings to @State do not trigger didSet:
struct CounterView: View {
@State var count = 0 {
didSet { print("This will not be called") }
}
var body: some View {
Form {
Stepper("\(count)", value: $count)
}
}
}
Any change made to count via the binding will not trigger didSet.
Is any of this behavior documented somewhere? There is no mention of this in the proposal. And is any of the behavior surprising or possibly considered a bug? It is a source of a lot of confusion to want to tap into the didSet of a property wrapper field and find that only under certain circumstances will it actually trigger.