What Are Property Wrappers?

Property wrappers, introduced in Swift 5.1, are a language feature that lets you define a reusable piece of behavior that can be attached to a property declaration. When you apply a property wrapper—using the @propertyWrapper attribute—the compiler synthesizes getter/setter logic and storage management according to your custom type. This abstraction reduces boilerplate, centralizes common patterns, and makes your state management code both cleaner and more maintainable.

At its core, a property wrapper is a struct or class that contains a wrappedValue property. The wrappedValue defines what gets returned when you read the property and how it’s updated when you assign a new value. You can also provide a projectedValue to expose additional functionality (like a binding or a publisher). This simple building block opens the door to elegant solutions for caching, validation, dependency injection, and—most importantly—state management in SwiftUI.

To understand why property wrappers are revolutionary for iOS, consider the traditional pre‑SwiftUI approach. Managing UI state meant manually hooking up KVO, delegates, or notification observers. With SwiftUI’s @State, @Binding, and @ObservedObject, the framework takes care of invalidating and redrawing views automatically. Property wrappers are the syntactic sugar that make this possible.

Built‑in Property Wrappers in SwiftUI

SwiftUI ships with several property wrappers specifically designed to manage state. Each one serves a distinct role in the data flow of your app.

@State

@State is the simplest and most frequently used wrapper. It manages a single source of truth that belongs to a view and is owned by that view. When the wrappedValue changes, SwiftUI automatically re‑renders any view that depends on it. Behind the scenes, the framework stores the value in a special heap‑allocated storage that persists across view updates.

import SwiftUI

struct CounterView: View {
    @State private var count = 0
    
    var body: some View {
        VStack {
            Text("Count: \(count)")
            Button("Increment") {
                count += 1
            }
        }
    }
}

Because @State is designed for small value types (Int, String, Bool, structs), you should avoid storing large models in it. Use @StateObject or @ObservedObject for complex reference‑type data.

@Binding

@Binding creates a two‑way connection between a property that stores state and a view that displays and mutates that state. It doesn’t own the data; instead, it provides read/write access to a source of truth owned elsewhere. This is essential for passing mutable state down the view hierarchy without breaking the principle of single‑source‑of‑truth.

struct ToggleView: View {
    @Binding var isOn: Bool
    
    var body: some View {
        Toggle("Enable", isOn: $isOn)
    }
}

struct ParentView: View {
    @State private var featureEnabled = false
    
    var body: some View {
        ToggleView(isOn: $featureEnabled)
    }
}

@ObservedObject and @StateObject

When you need to manage a reference‑type model (a class conforming to ObservableObject), you use @ObservedObject or @StateObject. @StateObject is owned by the view that creates it; the instance is created once and kept alive for the view’s lifetime. @ObservedObject is used when the view receives an object from its parent. Both cause the view to update whenever the object publishes changes via @Published properties.

class SettingsViewModel: ObservableObject {
    @Published var username = ""
}

struct SettingsView: View {
    @StateObject private var viewModel = SettingsViewModel()
    
    var body: some View {
        TextField("Username", text: $viewModel.username)
    }
}

@EnvironmentObject

For sharing a model across many views without passing it explicitly through every initializer, SwiftUI provides @EnvironmentObject. This wrapper reads an object from the environment of the view hierarchy. It’s a convenient way to inject dependencies like a user session, a theme manager, or an app coordinator.

struct AppView: View {
    @EnvironmentObject var session: SessionManager
    
    var body: some View {
        if session.isLoggedIn {
            DashboardView()
        } else {
            LoginView()
        }
    }
}

You inject the object at the top of the hierarchy using .environmentObject(_:). Any child view can then read it with @EnvironmentObject.

@AppStorage and @SceneStorage

These wrappers provide a quick way to persist state. @AppStorage stores a value in UserDefaults and automatically keeps it in sync. @SceneStorage is scoped to a single scene (useful for saving UI state across app restarts without using UserDefaults).

struct PreferencesView: View {
    @AppStorage("notificationsEnabled") private var notificationsEnabled = true
    
    var body: some View {
        Toggle("Enable Notifications", isOn: $notificationsEnabled)
    }
}

These built‑in wrappers cover most common needs, but the true power of property wrappers emerges when you design your own.

Creating Custom Property Wrappers

With a clear understanding of the built‑in set, you can extend the pattern to your own domain. A custom property wrapper is a type annotated with @propertyWrapper that implements a wrappedValue property. Optionally, you can add a projectedValue to surface an additional binding, publisher, or any other type.

Example 1: Clamping a Numeric Value

The original article showed a simple clamping wrapper. Let’s refine it to support any comparable value (Int, Float, Double) and add a projected value that returns the clamped value as an optional (or a flag indicating whether the last assignment was altered).

@propertyWrapper
struct Clamped<T: Comparable> {
    private var value: T
    private let range: ClosedRange<T>
    
    var projectedValue: Bool {
        return value != range.lowerBound && value != range.upperBound
    }
    
    init(wrappedValue: T, _ range: ClosedRange<T>) {
        self.range = range
        self.value = min(max(wrappedValue, range.lowerBound), range.upperBound)
    }
    
    var wrappedValue: T {
        get { value }
        set { value = min(max(newValue, range.lowerBound), range.upperBound) }
    }
}

struct PlayerView: View {
    @Clamped(0...100) var health: Int = 50
    
    var body: some View {
        Slider(value: $health, in: 0...100)
    }
}

Using @Clamped ensures that whatever the user or code assigns to health, the value always stays within the defined bounds. The $ prefix accesses the projected value (the binding), which SwiftUI can use for two‑way synchronization.

Example 2: UserDefaults Backed Storage

While @AppStorage already exists, building your own @UserDefault wrapper helps you understand the mechanics and gives you full control over serialization (e.g., codable models).

@propertyWrapper
struct UserDefault<T: Codable> {
    private let key: String
    private let defaultValue: T
    private let encoder = JSONEncoder()
    private let decoder = JSONDecoder()
    
    init(wrappedValue: T, _ key: String) {
        self.key = key
        self.defaultValue = wrappedValue
    }
    
    var wrappedValue: T {
        get {
            guard let data = UserDefaults.standard.data(forKey: key),
                  let value = try? decoder.decode(T.self, from: data) else {
                return defaultValue
            }
            return value
        }
        set {
            if let data = try? encoder.encode(newValue) {
                UserDefaults.standard.set(data, forKey: key)
            }
        }
    }
}

struct Settings {
    @UserDefault("userProfile") var profile: UserProfile = UserProfile()
}

This wrapper works with any type that conforms to Codable. Updating the property automatically persists the change, making state management across launches trivial.

Example 3: Input Validation

You can attach validation logic directly to a property. For instance, a wrapper that ensures a string is non‑empty or that an email matches a regex.

@propertyWrapper
struct ValidatedEmail {
    private var value: String = ""
    private let pattern = #"^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$"#
    
    var projectedValue: Bool {
        return value.range(of: pattern, options: .regularExpression, range: nil, locale: nil) != nil
    }
    
    init(wrappedValue: String) {
        self.wrappedValue = wrappedValue
    }
    
    var wrappedValue: String {
        get { value }
        set { value = newValue.trimmingCharacters(in: .whitespacesAndNewlines) }
    }
}

struct RegistrationView: View {
    @ValidatedEmail var email = ""
    
    var body: some View {
        VStack {
            TextField("Email", text: $email)
            if !$email {
                Text("Invalid email address")
                    .foregroundColor(.red)
            }
        }
    }
}

The projected value (accessed via $email in SwiftUI) returns a Boolean indicating validity. This pattern keeps validation logic cleanly separated from the view layer.

Example 4: Thread‑Safe Storage

In apps that perform heavy background work, you might need a property that is safe to read and write from any queue. A property wrapper can encapsulate a synchronization primitive (like an NSLock or a serial queue).

@propertyWrapper
struct ThreadSafe<T> {
    private let lock = NSLock()
    private var value: T
    
    init(wrappedValue: T) {
        self.value = wrappedValue
    }
    
    var wrappedValue: T {
        mutating get {
            lock.lock()
            defer { lock.unlock() }
            return value
        }
        set {
            lock.lock()
            defer { lock.unlock() }
            value = newValue
        }
    }
}

class CacheManager {
    @ThreadSafe var cache: [String: Data] = [:]
}

Now cache can be accessed from any thread without manually applying locks. This significantly reduces concurrency bugs.

Advanced Use Cases

Dependency Injection

Property wrappers can be used to inject services or dependencies at the property level. Combined with a global registry or the environment, you can avoid manual constructor injection throughout your views.

struct InjectionKey<T> {
    static var currentValue: T {
        get { fatalError("Must override") }
        set { }
    }
}

@propertyWrapper
struct Injected<T> {
    let keyPath: ReferenceWritableKeyPath<InjectionKeys, T>
    
    var wrappedValue: T {
        get { InjectionKeys.shared[keyPath: keyPath] }
        set { InjectionKeys.shared[keyPath: keyPath] = newValue }
    }
}

struct InjectionKeys {
    static var shared = InjectionKeys()
    var analyticsService: AnalyticsService = AnalyticsService()
}

class MyViewModel {
    @Injected(keyPath: \.analyticsService) var analytics
}

This pattern decouples concrete implementations from the code that uses them, making testing and swapping services effortless.

Combine Integration

Property wrappers can also project a Combine publisher. For example, you could create a @PublishedState wrapper that behaves like @Published from Combine but is available for properties that are not inside an ObservableObject.

@propertyWrapper
struct PublishedState<T> {
    private var value: T
    private let subject = PassthroughSubject<T, Never>()
    
    var projectedValue: AnyPublisher<T, Never> {
        subject.eraseToAnyPublisher()
    }
    
    init(wrappedValue: T) {
        self.value = wrappedValue
    }
    
    var wrappedValue: T {
        mutating get { value }
        set {
            value = newValue
            subject.send(newValue)
        }
    }
}

You can then combine this with SwiftUI updates or other combine pipelines without relying on ObservableObject.

Key‑Value Observing (KVO) Wrapper

If you still work with UIKit or older frameworks that rely on KVO, a property wrapper can automate the registration and cleanup.

@propertyWrapper
class KVOObservable<T: AnyObject, Value> {
    private let object: T
    private let keyPath: KeyPath<T, Value>
    private var observation: NSKeyValueObservation?
    
    var wrappedValue: Value {
        get { object[keyPath: keyPath] }
    }
    
    init(object: T, keyPath: KeyPath<T, Value>) {
        self.object = object
        self.keyPath = keyPath
        self.observation = object.observe(keyPath, options: .new) { _, _ in
            // Notify observers
        }
    }
}

This wrapper ensures the observation is established when the property is created and deallocated properly when the wrapper is released.

Best Practices When Using Property Wrappers

While property wrappers are powerful, overuse can lead to confusing code. Follow these guidelines to keep your state management clean.

  • Keep wrappers focused. Each wrapper should solve one specific concern. Avoid combining validation, persistence, and threading into a single wrapper.
  • Prefer built‑in wrappers for standard tasks. Don’t reinvent @State or @AppStorage unless you need custom behavior that they cannot provide.
  • Document projected values. If your wrapper exposes a $‑prefixed binding or a publisher, clearly state what it represents in the wrapper’s documentation comments.
  • Be careful with reference types. Property wrappers that store reference‑type objects may cause unexpected sharing. Use value semantics or ensure your wrapper manages copies appropriately.
  • Avoid large computational overhead in getters/setters. The getter and setter of wrappedValue are called frequently; keep them efficient.
  • Test your custom wrappers. Because a property wrapper intercepts assignments, subtle bugs can arise. Write unit tests for the wrapper’s logic independently of the views that use it.

Additionally, remember that property wrappers cannot be applied to computed properties, and their init parameters must be known at compile time. When designing wrappers with dynamic configuration, use the wrappedValue and projectedValue wisely.

Conclusion

Property wrappers in Swift provide an elegant mechanism for encapsulating state management logic, reducing boilerplate, and improving code clarity. From simple numeric clamping to full‑blown dependency injection, they give iOS developers a powerful tool for building maintainable and scalable apps. SwiftUI’s built‑in wrappers (@State, @Binding, @ObservedObject, etc.) already handle many common scenarios, but when your needs go beyond the standard set, you can easily create custom wrappers tailored to your domain.

By mastering property wrappers, you not only write less code but also create richer abstractions that are easier to reason about and test. Whether you are managing user preferences, validating input, or ensuring thread safety, property wrappers help you keep your state management clean and your codebase robust. Start by examining your current projects for repetitive patterns—chances are, a property wrapper can simplify them.

To learn more, explore Apple’s official documentation on Property Wrappers, the Swift Evolution proposal SE‑0258, and the SwiftUI State and Data Flow documentation. These resources provide the foundation for building sophisticated, production‑ready state management with property wrappers.