3. Property wrappers, result builders & key paths¶
What & why¶
Swift's declarative-looking features — @State, @Published, @ViewBuilder, \.name — are ordinary language features you can write yourself. Understanding how they work turns "magic" into tools you can use to build clean, domain-specific APIs.
Property wrappers¶
A property wrapper is a type that controls how a property is stored and accessed. Mark it @propertyWrapper and give it a wrappedValue:
@propertyWrapper
struct Clamped<Value: Comparable> {
private var value: Value
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 Player {
@Clamped(0...100) var health = 100
@Clamped(1...10) var level = 1
}
var p = Player()
p.health = 250
print(p.health) // 100
p.level = -5
print(p.level) // 1
Behind the scenes @Clamped(0...100) var health = 100 becomes a private stored property _health: Clamped<Int> plus a computed health that forwards to _health.wrappedValue.
Projected values ($)¶
A wrapper can expose extra API via projectedValue, reached with a $ prefix:
@propertyWrapper
struct Logged<Value> {
private var value: Value
private(set) var history: [Value] = []
init(wrappedValue: Value) { value = wrappedValue }
var wrappedValue: Value {
get { value }
set { history.append(value); value = newValue }
}
var projectedValue: [Value] { history } // accessed as $name
}
struct Thermostat {
@Logged var target = 20
}
var t = Thermostat()
t.target = 22
t.target = 25
print(t.$target) // [20, 22]
This is exactly how SwiftUI's $binding syntax works.
Practical wrappers¶
- Defaults/Storage:
@UserDefault("name", default: "")reading and writingUserDefaults. - Validation:
@Trimmed var username,@NonEmpty,@Clamped. - Thread-safety:
@Atomic,@Lockedguarding access with a lock (be careful: compound operations like+= 1are still two steps; wrap a method instead). - Observation/binding:
@State,@Binding,@Published,@Observable(the latter via macros now).
Costs and limits¶
- Each wrapped property adds a stored wrapper instance (small, but visible in layout).
- Wrappers can't be applied to
lazy, computed properties, or inenumcases. - Overuse hides control flow. Use them for cross-cutting, repeated behaviour; for one-off logic, write a normal property with
didSet.
Result builders¶
A result builder turns a sequence of statements into a single value. It is the mechanism behind SwiftUI's body and many DSLs (HTML, regex builders, Swift Charts).
@resultBuilder
struct HTMLBuilder {
static func buildBlock(_ parts: String...) -> String { parts.joined() }
static func buildOptional(_ part: String?) -> String { part ?? "" } // if without else
static func buildEither(first part: String) -> String { part } // if / else
static func buildEither(second part: String) -> String { part }
static func buildArray(_ parts: [String]) -> String { parts.joined() } // for-in
static func buildExpression(_ text: String) -> String { text }
}
func tag(_ name: String, @HTMLBuilder content: () -> String) -> String {
"<\(name)>\(content())</\(name)>"
}
let showFooter = true
let items = ["Swift", "Kotlin", "Rust"]
let page = tag("body") {
tag("h1") { "Languages" }
tag("ul") {
for item in items {
tag("li") { item }
}
}
if showFooter {
tag("footer") { "Thanks for reading" }
}
}
print(page)
// <body><h1>Languages</h1><ul><li>Swift</li><li>Kotlin</li><li>Rust</li></ul><footer>Thanks for reading</footer></body>
How it works: the compiler rewrites the closure body into calls to the builder's build… methods. The common ones:
| Method | Handles |
|---|---|
buildBlock(_:) |
combine a sequence of statements |
buildExpression(_:) |
convert each expression statement (and allow type mixing) |
buildOptional(_:) |
if without else |
buildEither(first:) / (second:) |
if/else and switch |
buildArray(_:) |
for ... in loops |
buildLimitedAvailability(_:) |
if #available |
buildFinalResult(_:) |
convert the combined value to the final return type |
Builders are best when the structure is naturally a tree or sequence of items. Type-check errors can be confusing, so keep builder code simple, and give the DSL a typed intermediate (an enum Node) rather than raw strings in real projects.
Use someone else's builder first
Most developers consume result builders (SwiftUI, RegexBuilder) far more often than they write them. Read Apple's RegexBuilder as a model of a well-designed one.
Key paths¶
A key path is a first-class reference to a property, without reading it:
struct Address { var city: String }
struct Person {
var name: String
var address: Address
}
let namePath = \Person.name // KeyPath<Person, String>
let cityPath = \Person.address.city // chained
let ada = Person(name: "Ada", address: Address(city: "London"))
print(ada[keyPath: namePath]) // Ada
print(ada[keyPath: cityPath]) // London
var grace = ada
grace[keyPath: \Person.name] = "Grace" // WritableKeyPath for var properties
Type hierarchy: KeyPath (read-only) → WritableKeyPath (value types) → ReferenceWritableKeyPath (classes).
Key paths as functions¶
\Person.name can be used where a (Person) -> String is expected:
let people = [ada, grace]
let names = people.map(\.name) // ["Ada", "Grace"]
let sorted = people.sorted(using: KeyPathComparator(\.name))
let grouped = Dictionary(grouping: people, by: \.address.city)
Generic code with key paths¶
extension Sequence {
func sum<N: AdditiveArithmetic>(of path: KeyPath<Element, N>) -> N {
reduce(.zero) { $0 + $1[keyPath: path] }
}
}
struct Item { var price: Double; var qty: Int }
let cart = [Item(price: 2.5, qty: 2), Item(price: 1, qty: 4)]
print(cart.sum(of: \.price), cart.sum(of: \.qty)) // 3.5 6
Key paths also power @dynamicMemberLookup forwarding, ObservableObject bindings, Core Data predicates, and the #Predicate macro.
@dynamicMemberLookup and @dynamicCallable¶
Type-safe or loose "dot syntax" for dynamic data:
@dynamicMemberLookup
struct JSON {
var storage: [String: Any]
subscript(dynamicMember key: String) -> Any? { storage[key] }
}
let json = JSON(storage: ["title": "Swift", "version": 6])
print(json.title as Any, json.version as Any) // Optional("Swift") Optional(6)
With a key path overload (subscript<T>(dynamicMember keyPath: KeyPath<Wrapped, T>) -> T), a wrapper type can forward member access to what it wraps with full type safety — the pattern behind Binding, Box, and proxies.
@dynamicCallable
struct Shell {
func dynamicallyCall(withArguments args: [String]) -> String { args.joined(separator: " ") }
}
print(Shell()("echo", "hi")) // echo hi
Both exist mostly for bridging dynamic languages (Python, JavaScript) — use sparingly in ordinary code.
Common mistakes¶
- Reaching for a wrapper for logic used once.
- Mutating through
wrappedValueininitbefore all stored properties are initialised (use_name = Wrapper(...)syntax in custom initialisers). - Complex logic inside builder closures — the type checker handles each build method call; huge bodies slow compilation. Extract helpers.
- Forgetting that builder closures aren't ordinary closures —
returnandvarmutation rules differ. - Key path performance assumptions: key paths allocate and are slower than direct property access. Don't use them in tight loops.
- Abusing
@dynamicMemberLookupand losing compile-time checks.
Exercises¶
- Write
@Trimmed var username: Stringthat strips whitespace on every assignment. - Write
@Persisted(key:)backed by a[String: Any]store (simulateUserDefaults) with a projected value that tells whether the stored value differs from the default. - Build a
@resultBuilderthat produces a[MenuItem]array and supportsif,for, and optional items. - Write
extension Sequence { func sorted<T: Comparable>(by path: KeyPath<Element, T>, descending: Bool = false) -> [Element] }. - Use
WritableKeyPathto implementfunc update<T, V>(_ object: inout T, _ path: WritableKeyPath<T, V>, to value: V)and use it on a nested struct. - Implement a typed forwarding wrapper
Proxy<Base>with@dynamicMemberLookupand key paths.
Recap¶
@propertyWrappertypes give properties reusable storage/access behaviour;$nameexposes the projected value.@resultBuilderrewrites a closure intobuild…calls — the engine of declarative DSLs.- Key paths (
\Type.property) reference properties as values and double as functions inmap/sorted. @dynamicMemberLookup/@dynamicCallableenable dynamic-feeling syntax; use them rarely.
Next: Macros →