Property Wrappers
Property wrappers let you attach reusable behaviour to a stored property — lazy initialization, validation, persistence, dependency injection, SwiftUI state — with a single @WrapperName annotation. Once you understand the pattern, the entire SwiftUI mental model clicks.
@State, @Binding, custom wrappers
EXAMPLE
import Foundation
import SwiftUI
import Combine
// 1) The shape — a property wrapper is a generic type marked @propertyWrapper
// with a wrappedValue.
@propertyWrapper
struct Clamped<Value: Comparable> {
private var value: Value
private let range: ClosedRange<Value>
init(wrappedValue: Value, _ range: ClosedRange<Value>) {
self.range = range
self.value = min(max(wrappedValue, range.lowerBound), range.upperBound)
}
var wrappedValue: Value {
get { value }
set { value = min(max(newValue, range.lowerBound), range.upperBound) }
}
}
struct Volume {
@Clamped(0...100) var level: Int = 50
}
var v = Volume()
v.level = 150
print(v.level) // 100 (clamped)
v.level = -10
print(v.level) // 0 (clamped)
// 2) projectedValue — the '$' companion
@propertyWrapper
struct Logged<Value> {
private var stored: Value
private(set) var history: [Value] = []
init(wrappedValue: Value) { self.stored = wrappedValue }
var wrappedValue: Value {
get { stored }
set { stored = newValue; history.append(newValue) }
}
var projectedValue: [Value] { history }
}
struct Counter {
@Logged var count: Int = 0
}
var c = Counter()
c.count = 1; c.count = 2; c.count = 3
print(c.count) // 3
print(c.$count) // [1, 2, 3] — accessed via the '$' projection
// 3) SwiftUI uses property wrappers everywhere
// @State — local, value-type, view-owned state
// @Binding — two-way reference to someone else's @State
// @StateObject — view-owned ObservableObject (created once)
// @ObservedObject — externally-owned ObservableObject
// @EnvironmentObject — ObservableObject injected via environment
// @Environment — read system values (colorScheme, locale, dismiss)
// @AppStorage — UserDefaults-backed property
// @SceneStorage — per-scene UI state restoration
// @FocusState — keyboard focus tracking
// @Published — Combine-friendly stored property on ObservableObject
struct CounterView: View {
@State private var count = 0
var body: some View {
VStack {
Text("Count: \(count)")
Button("+1") { count += 1 }
ChildView(value: $count) // pass binding
}
}
}
struct ChildView: View {
@Binding var value: Int
var body: some View {
Button("reset") { value = 0 } // writes through the binding
}
}
// 4) ObservableObject + @Published — shared state
final class CartModel: ObservableObject {
@Published var items: [String] = []
func add(_ item: String) { items.append(item) }
}
struct CartView: View {
@StateObject private var model = CartModel() // owned here
var body: some View {
VStack {
ForEach(model.items, id: \.self, content: Text.init)
Button("Add coffee") { model.add("coffee") }
}
}
}
struct CartCounterView: View {
@ObservedObject var model: CartModel // injected from parent
var body: some View { Text("\(model.items.count) items") }
}
// 5) @AppStorage — persistent settings
struct SettingsView: View {
@AppStorage("theme") var theme: String = "system"
@AppStorage("fontScale") var fontScale: Double = 1.0
var body: some View {
Form {
Picker("Theme", selection: $theme) {
Text("System").tag("system"); Text("Light").tag("light"); Text("Dark").tag("dark")
}
Slider(value: $fontScale, in: 0.8...1.5)
}
}
}
// Writes go to UserDefaults; the view automatically re-renders when changed.
// 6) Custom — a tiny dependency-injection wrapper
@propertyWrapper
struct Injected<T> {
private var resolved: T?
var wrappedValue: T {
mutating get {
if let r = resolved { return r }
let value: T = Container.resolve()
resolved = value
return value
}
}
}
enum Container {
static var network: NetworkService = LiveNetworkService()
static var auth: AuthService = LiveAuthService()
static func resolve<T>() -> T {
if T.self == NetworkService.self { return network as! T }
if T.self == AuthService.self { return auth as! T }
fatalError("No registration for \(T.self)")
}
}
class UserService {
@Injected var network: NetworkService
@Injected var auth: AuthService
}
// 7) Codable + property wrappers — defaults at decode time
@propertyWrapper
struct Default<T: Codable>: Codable {
let fallback: T
var wrappedValue: T
init(_ fallback: T) { self.fallback = fallback; self.wrappedValue = fallback }
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let decoded = try? container.decode(T.self)
self.fallback = decoded ?? (try Self(from: decoder).fallback)
self.wrappedValue = decoded ?? self.fallback
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(wrappedValue)
}
}
struct Profile: Codable {
var name: String
@Default(true) var notifications: Bool
@Default([]) var tags: [String]
}
// Now missing keys default to a value during JSONDecoder run.
// 8) Combine — @Published on ObservableObject
final class SearchVM: ObservableObject {
@Published var query: String = ""
@Published var results: [String] = []
private var cancellables = Set<AnyCancellable>()
init() {
$query
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
.removeDuplicates()
.sink { [weak self] q in self?.search(q) }
.store(in: &cancellables)
}
private func search(_ q: String) { /* … */ }
}
// 9) Limitations / gotchas
// • Wrappers can't be applied to computed properties
// • Wrappers can't access the enclosing instance directly (need @propertyWrapper enclosing-self trick)
// • You can't combine certain wrappers (e.g. @State + @Published) — design around it
// • Inherits Sendable / Codable issues from the wrapped type
// 10) The 'enclosing self' pattern (for wrappers that need to read other properties)
// See Swift evolution proposal SE-0258; used by SwiftUI internally. Rarely needed in app code.
// 11) Common bugs
// • @State on a non-View — only works in SwiftUI views
// • @StateObject created in body — re-created every render; declare at the view init level
// • @ObservedObject without an owner higher up — its lifetime is tied to the parent
// • Forgetting to pass a binding ('value' instead of '$value') — child gets a snapshot, not a binding
// • Wrapper stores reference to value via init but never updates wrappedValue setter — stale state
// • Custom wrappers with mutating get used inside non-mutating context — compiler error; mark struct mutating
Why it matters
Property wrappers turn cross-cutting concerns — clamping, persistence, validation, dependency injection, SwiftUI state — into a single annotation. Use the built-ins for SwiftUI (@State, @Binding, @StateObject, @AppStorage), and write your own when the same boilerplate keeps showing up; the $ projection is your hook for exposing extra API like history or a publisher.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
@propertyWrapper
struct Trimmed {
private var value: String = ""
var wrappedValue: String {
get { value }
set { value = newValue.trimmingCharacters(in: .whitespaces) }
}
init(wrappedValue: String) { self.wrappedValue = wrappedValue }
}
Try it Yourself »
Discussion
Loading…