09 · Property Wrappers Intro¶
Property wrappers let you extract a repeated piece of "get/set" behavior —
validation, clamping, storage tricks — into a reusable type, and then apply
it to any property with a single @ annotation. If you've used SwiftUI's
@State or @Published, you've already used property wrappers; this
module shows what's actually happening underneath them.
The repeated-logic problem¶
Imagine several properties that all need the same clamping behavior:
struct Settings {
private var _volume: Int = 50
var volume: Int {
get { _volume }
set { _volume = min(max(newValue, 0), 100) } // clamp to 0...100
}
private var _brightness: Int = 50
var brightness: Int {
get { _brightness }
set { _brightness = min(max(newValue, 0), 100) } // same clamping logic, duplicated
}
}
The clamping logic is identical for both properties — copy-pasted, which means a future change (say, a different valid range) has to be made in every copy. A property wrapper factors this out once.
Defining a property wrapper¶
A property wrapper is a struct (or class) annotated @propertyWrapper,
with a required wrappedValue:
@propertyWrapper
struct Clamped {
private var value: Int
private let range: ClosedRange<Int>
var wrappedValue: Int {
get { value }
set { value = min(max(newValue, range.lowerBound), range.upperBound) }
}
init(wrappedValue: Int, _ range: ClosedRange<Int>) {
self.range = range
self.value = min(max(wrappedValue, range.lowerBound), range.upperBound)
}
}
Using it collapses the whole get/set dance to one line per property:
struct BetterSettings {
@Clamped(0...100) var volume: Int = 50
@Clamped(0...100) var brightness: Int = 75
}
var settings = BetterSettings()
settings.volume = 150
print(settings.volume) // 100 -- clamped automatically
settings.brightness = -20
print(settings.brightness) // 0 -- clamped automatically
@Clamped(0...100) var volume: Int = 50 desugars to something close to
private var _volume = Clamped(wrappedValue: 50, 0...100), with volume
becoming a computed property that reads and writes through _volume.wrappedValue
— the @ syntax is compiler sugar over exactly that pattern.
A validating wrapper¶
Property wrappers are equally useful for validation, not just clamping:
@propertyWrapper
struct NonEmpty {
private var value: String
var wrappedValue: String {
get { value }
set { value = newValue.isEmpty ? "Unnamed" : newValue }
}
init(wrappedValue: String) {
self.value = wrappedValue.isEmpty ? "Unnamed" : wrappedValue
}
}
struct Author {
@NonEmpty var name: String
}
var author = Author(name: "Toni")
print(author.name) // Toni
author.name = ""
print(author.name) // Unnamed -- the wrapper silently substitutes a default
projectedValue: exposing extra information with $¶
A property wrapper can expose a second value — accessed with a $ prefix —
alongside the main wrapped value. This is how SwiftUI's @State gives you
both the plain value (count) and a Binding ($count):
import Foundation
@propertyWrapper
struct Trimmed {
private var value: String = ""
private(set) var wasTrimmed = false // tracks whether trimming actually changed anything
var wrappedValue: String {
get { value }
set {
let trimmedValue = newValue.trimmingCharacters(in: .whitespaces)
wasTrimmed = trimmedValue != newValue
value = trimmedValue
}
}
var projectedValue: Bool {
wasTrimmed
}
init(wrappedValue: String) {
self.wrappedValue = wrappedValue
}
}
struct Comment {
@Trimmed var text: String = ""
}
var comment = Comment()
comment.text = " hello world "
print(comment.text) // hello world
print(comment.$text) // true -- accessed via the $ prefix, exposes projectedValue
Built-in property wrappers you'll meet in the wild¶
You won't write most property wrappers yourself day-to-day — Foundation and SwiftUI ship several for common patterns:
| Wrapper | Comes from | Purpose |
|---|---|---|
@State |
SwiftUI | Local, mutable view state that triggers a UI refresh on change |
@Published |
Combine | Marks a property whose changes broadcast to subscribers |
@ObservedObject / @StateObject |
SwiftUI | Watches an external reference type for changes |
@AppStorage |
SwiftUI | Reads/writes a value directly to UserDefaults |
@Binding |
SwiftUI | A two-way reference to state owned by another view |
Even without a SwiftUI project, understanding the mechanics above means none of these are "magic" the first time you see them.
A trap: wrappers and memberwise initializers¶
Adding a property wrapper to a struct property can change or remove the
automatic memberwise initializer, depending on how the wrapper's own init
is defined — this trips people up when refactoring a plain struct into one
using wrappers:
struct Config {
@Clamped(0...10) var level: Int = 5
}
// Config(level: 5) still works here because Clamped's init(wrappedValue:_:)
// lets the compiler synthesize a matching memberwise initializer.
let config = Config(level: 5)
print(config.level) // 5
If a wrapper's initializer doesn't line up this cleanly (for example, if it
required extra parameters with no defaults), the memberwise initializer can
disappear entirely, forcing you to write init by hand — always check by
trying to construct the type after adding a wrapper.
How It Actually Works¶
- A property wrapper is pure compiler desugaring —
@Wrapper var x: Int = 5is rewritten by the compiler into a hidden stored property_x: Wrapper<Int>initialized asWrapper(wrappedValue: 5), plus a computed propertyxwhose getter/setter simply forward to_x.wrappedValue. Nothing about this is runtime magic — you could write the exact same expansion by hand, and you can even see it by inspecting the compiler's generated interface. projectedValueand the$prefix work the same way:$xdesugars to_x.projectedValue, a second computed property the compiler synthesizes only if your wrapper type declares aprojectedValue. This is purely a naming convention the compiler recognizes (the leading$), not a separate language feature.- Memberwise initializer interaction: because a wrapped property is
secretly backed by a different stored property type (
Wrapper<Int>, notInt), the compiler's synthesized memberwise initializer for a struct with wrapped properties takes the wrapped type as its parameter by default (calling the wrapper'sinit(wrappedValue:)) — unless the wrapper doesn't provide that initializer, in which case no memberwise init is synthesized at all, which is the "trap" this chapter's example demonstrates. - SwiftUI's
@State/@Binding/@ObservedObjectare just property wrappers built on this exact same compile-time mechanism, layered with runtime storage that ties into SwiftUI's own diffing/invalidation system (see the SwiftUI chapter) — there's no separate "SwiftUI property" language feature, only this general-purpose wrapper mechanism used to build framework-specific behavior.
Exercise¶
Write a property wrapper @propertyWrapper struct Uppercased that stores a
String and always uppercases it on set (so reading wrappedValue never
returns anything but uppercase). Apply it to a var code: String inside a
struct Coupon, set it to "save20", and confirm it prints "SAVE20".
Then add a projectedValue: Int to the wrapper that exposes the original
(pre-uppercasing) string's character count, and print coupon.$code to
confirm it reports the right length.