Property Wrappers and Macros
What a property wrapper compiles to, property observers and `lazy`, KeyPaths, and how a Swift macro like `@Observable` differs from a wrapper.
10 questions
JuniorTheoryVery commonIn willSet/didSet, what are newValue and oldValue, and can a computed property have observers?
In willSet/didSet, what are newValue and oldValue, and can a computed property have observers?
willSet runs before the store and exposes the incoming value as newValue; didSet runs after it and exposes the previous value as oldValue. A computed property cannot have observers — put that logic in its get/set.
Common mistakes
- ✗Swapping which observer sees the incoming value versus the previous one
- ✗Trying to attach
willSet/didSetto a computed property - ✗Expecting observers to fire on reads rather than only on writes
Follow-up questions
- →Can you rename
newValue/oldValue, and what type do they take? - →Why does a computed property put change logic in its
setrather than adidSet?
JuniorTheoryVery commonHow does a stored property differ from a computed one, and what does static change about it?
How does a stored property differ from a computed one, and what does static change about it?
A stored property holds a value in the instance's own memory; a computed property has no storage and runs get/set on each access. static binds the property to the type, not an instance — one value stored once per type.
Common mistakes
- ✗Thinking a computed property reserves storage like a stored one
- ✗Believing a
staticproperty lives once per instance rather than once per type - ✗Assuming
staticis only about access control or read-only
Follow-up questions
- →How does
staticdiffer fromclasswhen declaring a type property? - →When is a
staticstored property actually initialized?
JuniorTheoryCommonWhen is a lazy var initialized, why can it not be a let, and is it thread-safe?
When is a lazy var initialized, why can it not be a let, and is it thread-safe?
A lazy var runs its initializer on first access, not at object creation, so it can reference self. It stays a var because that first access writes its backing slot, and it is not thread-safe under concurrent first reads.
Common mistakes
- ✗Believing a
lazyproperty is initialized eagerly alongside the other stored properties - ✗Assuming lazy initialization is automatically thread-safe
- ✗Thinking a
lazy letis allowed
Follow-up questions
- →Why can a
lazy varinitializer referenceselfwhen a normal stored property's cannot? - →How would you make a
lazyproperty safe under concurrent first access?
MiddleDebuggingCommonWhy don't willSet/didSet fire for an assignment inside init, and how do you work around it?
Why don't willSet/didSet fire for an assignment inside init, and how do you work around it?
Inside init the object is not fully initialized, so the first assignment installs the value and Swift skips the observers. Work around it by running the effect after init, or by using a property wrapper whose setter always runs.
Common mistakes
- ✗Expecting
didSetside effects to run for the value assigned ininit - ✗Blaming the observers being stripped by the presence of a custom initializer
- ✗Reaching for
lazyinstead of running the effect explicitly after init
Follow-up questions
- →Do observers run when a property is set through a method called from
init? - →Why does a property wrapper's setter run for the initializing assignment?
MiddleTheoryCommonAmong static let, class var, and a global, which are lazily initialized and thread-safe?
Among static let, class var, and a global, which are lazily initialized and thread-safe?
A static let and a top-level global initialize lazily on first access, exactly once, and the runtime makes that initialization thread-safe. A class var must be computed — it has no stored slot to initialize lazily.
Common mistakes
- ✗Assuming a
static letis initialized eagerly at launch rather than on first use - ✗Expecting a stored
class var, whenclass/staticcomputed is required for overriding - ✗Believing a local
lazy vargets the same once-only thread safety as astatic
Follow-up questions
- →Why is a
static letthread-safe when alazy varon an instance is not? - →What mechanism guarantees a global is initialized exactly once?
SeniorDesignCommonYou are migrating a SwiftUI screen whose view model is a class conforming to the ObservableObject protocol with several @Published properties. A colleague asks you to move it to the iOS 17 @Observable macro instead. Explain what the @Observable macro generates on the class, how per-property change tracking differs from the whole-object objectWillChange signal that @Published fires, and why that makes view updates cheaper. State how the view's ownership changes — which SwiftUI property wrapper replaces @StateObject — and name one behavioral difference a careless migration could regress, such as a computed property that reads a tracked stored property. Keep the answer to the observation and rendering consequences, not unrelated refactors.
You are migrating a SwiftUI screen whose view model is a class conforming to the ObservableObject protocol with several @Published properties. A colleague asks you to move it to the iOS 17 @Observable macro instead. Explain what the @Observable macro generates on the class, how per-property change tracking differs from the whole-object objectWillChange signal that @Published fires, and why that makes view updates cheaper. State how the view's ownership changes — which SwiftUI property wrapper replaces @StateObject — and name one behavioral difference a careless migration could regress, such as a computed property that reads a tracked stored property. Keep the answer to the observation and rendering consequences, not unrelated refactors.
The @Observable macro rewrites the class to track each stored property individually via the Observation framework, so only the property that changes re-renders its readers. ObservableObject instead fires one objectWillChange for the whole object, so every observer recomputes. Pair the model with @State, not @StateObject.
Common mistakes
- ✗Assuming
@Observablestill emits a whole-object change like@Published - ✗Keeping
@StateObject/@ObservedObjectinstead of moving to@State - ✗Believing tracking is done by runtime KVO rather than compile-time macro expansion
Follow-up questions
- →Why must a view read a property in
bodyfor@Observableto track it? - →What happens to a computed property that derives from a tracked stored one?
MiddleCodeOccasionalWrite a @Clamped property wrapper that keeps a value inside a range
Write a @Clamped property wrapper that keeps a value inside a range
A @propertyWrapper struct holds the ClosedRange and a backing value; its wrappedValue setter clamps via min(max(...)), and init clamps the first value too. Every write is forced back into the range.
Common mistakes
- ✗Forgetting to clamp the initial value passed through
init(wrappedValue:) - ✗Clamping inside a
didSet, which never runs for theinitassignment - ✗Storing the raw value and clamping only when the property is read
Follow-up questions
- →How would
@Clampedexpose aprojectedValuereachable through$? - →Why must
Valueconform toComparablefor the clamp to compile?
MiddleTheoryOccasionalWhat is a key path like \User.name, how does WritableKeyPath differ, and why pass one to a generic function?
What is a key path like \User.name, how does WritableKeyPath differ, and why pass one to a generic function?
A key path is a stored, typed reference to a property — \User.name is a KeyPath<User, String>, and WritableKeyPath also permits writing. A generic can take a KeyPath<Root, Value> instead of a (Root) -> Value closure, e.g. users.map(\.name).
Common mistakes
- ✗Treating a key path as a closure rather than a stored, comparable value
- ✗Confusing a read-only
KeyPathwith aWritableKeyPath - ✗Thinking key paths are untyped strings resolved by runtime reflection
Follow-up questions
- →How does
\.nameshorthand infer itsRoottype? - →When would you need a
ReferenceWritableKeyPathinstead?
MiddleTheoryOccasionalWhat does a property wrapper compile down to, and what is the $ prefix really?
What does a property wrapper compile down to, and what is the $ prefix really?
The compiler turns @Wrapper var x into a hidden _x of the wrapper's type, and every read or write of x goes through wrappedValue. The $x prefix exposes the wrapper's optional projectedValue, not a built-in binding.
Common mistakes
- ✗Believing
$always yields a SwiftUIBindingrather than the wrapper'sprojectedValue - ✗Thinking the wrapper is applied at runtime instead of synthesized by the compiler
- ✗Forgetting that
_xis the actual wrapper instance behind the property
Follow-up questions
- →When does a wrapper have no
$projection available? - →How can code reach the underlying wrapper instance directly?
SeniorTheoryRareFor a stored property, computed property, property wrapper, and Swift macro — what is resolved at compile time vs runtime?
For a stored property, computed property, property wrapper, and Swift macro — what is resolved at compile time vs runtime?
Storage layout, a wrapper's backing struct, and a macro's expansion are fixed at compile time. At runtime only accessors run — a computed get/set and a wrapper's wrappedValue. A macro adds no runtime machinery; it is pure source generation.
Common mistakes
- ✗Believing a macro leaves runtime machinery instead of expanding to plain compiled code
- ✗Thinking property wrappers are applied by runtime reflection
- ✗Assuming computed accessors are resolved entirely at compile time with no runtime call
Follow-up questions
- →What does a macro leave in the compiled binary versus a property wrapper?
- →Where does a computed property incur its only runtime cost?