Skip to content

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 writing UserDefaults.
  • Validation: @Trimmed var username, @NonEmpty, @Clamped.
  • Thread-safety: @Atomic, @Locked guarding access with a lock (be careful: compound operations like += 1 are 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 in enum cases.
  • 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 wrappedValue in init before 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 — return and var mutation rules differ.
  • Key path performance assumptions: key paths allocate and are slower than direct property access. Don't use them in tight loops.
  • Abusing @dynamicMemberLookup and losing compile-time checks.

Exercises

  1. Write @Trimmed var username: String that strips whitespace on every assignment.
  2. Write @Persisted(key:) backed by a [String: Any] store (simulate UserDefaults) with a projected value that tells whether the stored value differs from the default.
  3. Build a @resultBuilder that produces a [MenuItem] array and supports if, for, and optional items.
  4. Write extension Sequence { func sorted<T: Comparable>(by path: KeyPath<Element, T>, descending: Bool = false) -> [Element] }.
  5. Use WritableKeyPath to implement func update<T, V>(_ object: inout T, _ path: WritableKeyPath<T, V>, to value: V) and use it on a nested struct.
  6. Implement a typed forwarding wrapper Proxy<Base> with @dynamicMemberLookup and key paths.

Recap

  • @propertyWrapper types give properties reusable storage/access behaviour; $name exposes the projected value.
  • @resultBuilder rewrites a closure into build… calls — the engine of declarative DSLs.
  • Key paths (\Type.property) reference properties as values and double as functions in map/sorted.
  • @dynamicMemberLookup / @dynamicCallable enable dynamic-feeling syntax; use them rarely.

Next: Macros →