Property wrappers
The mechanism behind @State, @AppStorage and @Published — and how to build your own to delete repeated code.
By the end you will be able to
- Explain what the compiler generates for a wrapped property
- Read `_name` and `$name` and say which piece each one is
- Build a wrapper that removes real duplication from a model
You have seen @State private var count = 0 in SwiftUI. What kind of thing is @State?
The problem it solves
Every codebase accumulates properties that need the same ceremony around every access. A settings screen, before wrappers:
final class Settings {
var volume: Int {
get { readFromDisk("volume") ?? 5 }
set { writeToDisk("volume", min(10, max(0, newValue))) }
}
var brightness: Int {
get { readFromDisk("brightness") ?? 7 }
set { writeToDisk("brightness", min(10, max(0, newValue))) }
}
// …and eight more, identical except for the key.
}
The clamping logic and the disk round-trip are duplicated per property, and every new setting is a chance to copy the pattern slightly wrong. This shape — per-property behaviour that is identical across many properties — is exactly what property wrappers exist for. In real projects you meet it constantly: values that must be clamped, strings that must be trimmed, settings that must persist, fields that must be validated, access that must be logged.
What a wrapper is
A property wrapper is a type marked @propertyWrapper with one requirement: a property named wrappedValue.
@propertyWrapper
struct Clamped {
private var value: Int
let range: ClosedRange<Int>
var wrappedValue: Int {
get { value }
set { value = min(range.upperBound, max(range.lowerBound, newValue)) }
}
init(wrappedValue: Int, _ range: ClosedRange<Int>) {
self.range = range
self.value = min(range.upperBound, max(range.lowerBound, wrappedValue))
}
}
Using it deletes the duplication:
struct AudioSettings {
@Clamped(0...10) var volume = 5
@Clamped(0...10) var brightness = 7
}
Every read and write of volume now goes through Clamped.wrappedValue. The rule lives in one place; ten properties share it.
What the compiler actually generates
This is the part worth genuinely understanding, because it explains every _ and $ you will ever see in SwiftUI. For @Clamped(0...10) var volume = 5, the compiler writes, mechanically:
private var _volume = Clamped(wrappedValue: 5, 0...10) // the storage
var volume: Int { // the façade
get { _volume.wrappedValue }
set { _volume.wrappedValue = newValue }
}
That is the whole feature. @Wrapper var x is sugar for a hidden stored property _x of the wrapper type, plus a computed x that passes through it. No runtime magic — a source transformation you could do by hand.
And because the runtime in this page has no macro expansion, doing it by hand is exactly how we will run it — which has the nice side effect of proving there is nothing else hiding in there:
In Xcode you write the two-line sugared version; the behaviour is identical to this expansion.
$ — the projected value
A wrapper can expose a second interface by declaring projectedValue. Whatever type that property returns is what $name gives you.
@propertyWrapper
struct Logged {
private var value: Int
private(set) var projectedValue: [String] = [] // $property → the history
var wrappedValue: Int {
get { value }
set {
projectedValue.append("\(value) → \(newValue)")
value = newValue
}
}
init(wrappedValue: Int) { self.value = wrappedValue }
}
Now the two prefixes make a complete picture:
| You write | You get | It is |
|---|---|---|
name | the value | _name.wrappedValue |
$name | the projection | _name.projectedValue |
_name | the wrapper itself | the hidden storage property |
This is precisely why $searchText in SwiftUI is a Binding<String>: State.projectedValue is declared as Binding<Value>. Nothing about $ is specific to bindings — @Published's projection is a Combine publisher, and yours can be anything useful.
The desugared Logged wrapper records every change. What does this print?
struct Logged {
private var value: Int
var projectedValue: [String] = []
var wrappedValue: Int {
get { value }
set {
projectedValue.append("\(value) -> \(newValue)")
value = newValue
}
}
init(wrappedValue: Int) { self.value = wrappedValue }
}
struct Thermostat {
private var _target = Logged(wrappedValue: 20)
var target: Int {
get { _target.wrappedValue }
set { _target.wrappedValue = newValue }
}
var history: [String] { _target.projectedValue }
}
var thermostat = Thermostat()
thermostat.target = 22
thermostat.target = 18
thermostat.target = 18
print(thermostat.target)
print(thermostat.history)The wrappers you already use
Now every SwiftUI attribute you have met is readable as ordinary code:
| Wrapper | wrappedValue | $ projection | Job |
|---|---|---|---|
@State | your value, read from SwiftUI's storage | Binding<Value> | view-owned state |
@Binding | someone else's value | the binding itself | write access passed down |
@AppStorage("key") | value persisted in UserDefaults | Binding<Value> | settings that survive relaunch |
@Environment | value from the environment | — | dependency passed down the tree |
@Published (Combine) | the value | a publisher of changes | pre-Observation models |
@AppStorage is worth pausing on, because it is the disk-backed settings problem from the top of this lesson, solved by Apple with this exact feature: @AppStorage("volume") var volume = 5 reads and writes UserDefaults on every access, and projects a binding so a Slider can edit it directly. The ceremony you would have written per-property lives once, inside the wrapper.
A teammate writes @Clamped(0...100) var progress = 50 and later, in an initializer, needs to replace the whole wrapper with one using a different range. What do they assign to?
Build a Trimmed wrapper
User input arrives with stray whitespace, and every field needs the same cleanup. Build the desugared Trimmed wrapper: its setter (and initializer) trim whitespace from both ends, so no call site can forget.
Project a validation flag
Extend the pattern: Validated stores an email and projects whether it currently looks valid, the way $field exposes extra information in SwiftUI forms.
Pick a repetitive rule from a codebase you know — "all prices round to 2 decimal places", "all identifiers are lowercase", "all dates are stored as UTC". Sketch the wrapper: its stored value, what the setter enforces, and what (if anything) $ should project. Then write one sentence on where the rule lived before, and what could go wrong there.
You can now:
- Expand
@Wrapper var xinto the storage + façade the compiler generates - Read
_xand$xfor what they literally are - Build wrappers with
wrappedValue,init(wrappedValue:)and a projection - Explain
@State,@AppStorageand@Publishedas instances of one feature
Next up: result builders — the other half of SwiftUI's machinery, and why VStack { Text() Text() } is legal Swift.