Skip to content

Custom Property Wrappers (Swift)

A SwiftUI guide for coding agents. Also covers custom property wrapper, propertywrapper attribute, keychain storage wrapper, dynamicproperty protocol, projectedvalue binding, appstorage for keychain.

A property wrapper extracts property logic into a reusable type, the way @AppStorage wraps user defaults. This recipe builds a @SecureStorage wrapper for the Keychain that also updates SwiftUI views when its value changes.

The Wrapper Type

Annotate a type with @propertyWrapper and give it a wrappedValue property. Swift routes every read and write of the wrapped property through it. Swift calls init(wrappedValue:key:) automatically when the property declares a default value.

@propertyWrapper struct SecureStorage<Value: Codable> {
  private let key: String
  private let initialValue: Value
  private let decoder = JSONDecoder()
  private let encoder = JSONEncoder()
  private let keychain = Keychain(service: Constants.sharedGroup)

  init(wrappedValue initialValue: Value, key: String) {
    self.key = key
    self.initialValue = initialValue
  }

  var wrappedValue: Value {
    get {
      guard
        let data = try? keychain.getData(key),
        let value = try? decoder.decode(Value.self, from: data)
      else { return initialValue }
      return value
    }
    set {
      guard let data = try? encoder.encode(newValue) else { return }
      try? keychain.set(data, key: key)
    }
  }
}

A wrapper is also a seam for third-party code: the codebase sees only @SecureStorage, so swapping the KeychainAccess package later touches one file.

Projected Value

Implement projectedValue to make the $ syntax work, the way SwiftUI wrappers hand out bindings. The projected value can be any type, not only Binding.

var projectedValue: Binding<Value> {
  .init(
    get: { wrappedValue },
    set: { wrappedValue = $0 }
  )
}

Driving SwiftUI Updates

A plain wrapper does not refresh views. Two additions fix that: conform the wrapper to DynamicProperty, and hold the state in an inner ObservableObject observed with @ObservedObject so a write publishes a change. Note the nonmutating set, which lets a struct-held wrapper write through the class.

private final class KeychainStorage<Value: Codable>: ObservableObject {
  let objectWillChange = PassthroughSubject<Void, Never>()

  var value: Value {
    get { fetch() }
    set {
      objectWillChange.send()
      save(newValue)
    }
  }
  // save(_:) encodes to the keychain, fetch() decodes or returns the default
}

@propertyWrapper struct SecureStorage<Value: Codable>: DynamicProperty {
  @ObservedObject private var storage: KeychainStorage<Value>

  var wrappedValue: Value {
    get { storage.value }
    nonmutating set { storage.value = newValue }
  }

  init(wrappedValue: Value, _ key: String) {
    storage = KeychainStorage(defaultValue: wrappedValue, for: key)
  }

  var projectedValue: Binding<Value> {
    .init(get: { wrappedValue }, set: { wrappedValue = $0 })
  }
}

Usage reads like the built-in wrappers: @SecureStorage(Settings.goalKey) var goal = 150, with $goal feeding a Stepper and the view refreshing on every write.

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-swiftui-guide({ topic: "swiftui-how-to-create-a-property-wrapper-in-swift" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems