Advanced Swift · advanced

Property wrappers

The mechanism behind @State, @AppStorage and @Published — and how to build your own to delete repeated code.

12 min read13 min practice0/2 exercises4 recall cards

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
Guess firstAnswering before you read makes the explanation stick — even when you get it wrong.

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:

The desugared form — exactly what the compiler generates for @Clamped.

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 writeYou getIt is
namethe value_name.wrappedValue
$namethe projection_name.projectedValue
_namethe wrapper itselfthe 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.

Predict, then runA wrong prediction you have committed to is worth more than a right answer you read.

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:

WrapperwrappedValue$ projectionJob
@Stateyour value, read from SwiftUI's storageBinding<Value>view-owned state
@Bindingsomeone else's valuethe binding itselfwrite access passed down
@AppStorage("key")value persisted in UserDefaultsBinding<Value>settings that survive relaunch
@Environmentvalue from the environmentdependency passed down the tree
@Published (Combine)the valuea publisher of changespre-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.

Check yourself

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

Write the code

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.

Solution unlocks after 3 attempts

Project a validation flag

Write the code

Extend the pattern: Validated stores an email and projects whether it currently looks valid, the way $field exposes extra information in SwiftUI forms.

Solution unlocks after 3 attempts
Design itSaved on this device. Never graded.

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.

Checkpoint

You can now:

  • Expand @Wrapper var x into the storage + façade the compiler generates
  • Read _x and $x for what they literally are
  • Build wrappers with wrappedValue, init(wrappedValue:) and a projection
  • Explain @State, @AppStorage and @Published as instances of one feature

Next up: result builders — the other half of SwiftUI's machinery, and why VStack { Text() Text() } is legal Swift.

How well do you know this now? Rating yourself honestly, then being tested on it, is how you find out where your intuition is wrong.