Swift Property Wrappers - Practical Implementation Patterns

Property wrappers in Swift represent one of the language's most elegant features, enabling developers to encapsulate complex property behaviors behind simple, reusable interfaces. Rather than cluttering your code with repetitive boilerplate, property wrappers let you define custom logic once and apply it consistently across your codebase.

Let's explore several real-world scenarios where property wrappers shine.

Simplifying UserDefaults Access

Managing UserDefaults typically involves verbose getter and setter code scattered throughout your application. A property wrapper can eliminate this repetition entirely:

@propertyWrapper
struct UserDefault<T> {
    let key: String
    let defaultValue: T
 
    init(_ key: String, defaultValue: T) {
        self.key = key
        self.defaultValue = defaultValue
    }
 
    var wrappedValue: T {
        get {
            return UserDefaults.standard.object(forKey: key) as? T ?? defaultValue
        }
        set {
            UserDefaults.standard.set(newValue, forKey: key)
        }
    }
}

With this wrapper, persistent storage becomes incredibly straightforward:

class AppPreferences {
    @UserDefault("has_completed_tutorial", defaultValue: false)
    static var hasCompletedTutorial: Bool
}

Enforcing Value Boundaries

Sometimes you need to ensure a property stays within acceptable limits. A clamping wrapper handles this automatically:

@propertyWrapper
struct Clamping<Value: Comparable> {
    var value: Value
    let range: ClosedRange<Value>
 
    init(wrappedValue: Value, _ range: ClosedRange<Value>) {
        precondition(range.contains(wrappedValue))
        self.value = wrappedValue
        self.range = range
    }
 
    var wrappedValue: Value {
        get { value }
        set {
            value = min(max(range.lowerBound, newValue), range.upperBound)
        }
    }
}

Now you can guarantee that values remain within bounds:

struct GameScore {
    @Clamping(0...100)
    var healthPercentage: Int = 100
}

Observing Property Changes

Executing code whenever a property changes is a common requirement. A change-tracking wrapper makes this seamless:

@propertyWrapper
struct DidChange<Value> {
    private var value: Value
    let action: (Value) -> Void
 
    init(wrappedValue: Value, action: @escaping (Value) -> Void) {
        self.value = wrappedValue
        self.action = action
    }
 
    var wrappedValue: Value {
        get { value }
        set {
            value = newValue
            action(value)
        }
    }
}

Use it to automatically respond to state changes:

class DataModel {
    @DidChange(action: { print("Status updated to: \($0)") })
    var status = "idle"
}

Streamlining Dependency Injection

Dependency injection becomes much cleaner with property wrappers. Here's a simple container-based approach:

@propertyWrapper
struct Injected<Service> {
    var wrappedValue: Service {
        return ServiceContainer.shared.resolve()
    }
}
 
class ServiceContainer {
    static let shared = ServiceContainer()
    private var services: [String: Any] = [:]
 
    func register<Service>(_ service: Service) {
        let key = "\(Service.self)"
        services[key] = service
    }
 
    func resolve<Service>() -> Service {
        let key = "\(Service.self)"
        guard let service = services[key] as? Service else {
            fatalError("Service of type \(key) not registered.")
        }
        return service
    }
}

Your classes can now declare their dependencies declaratively:

protocol NetworkService {
    func fetchData() -> Data
}
 
class APINetworkService: NetworkService {
    func fetchData() -> Data {
        // Implementation here
    }
}
 
class ViewController {
    @Injected var networkService: NetworkService
}
 
// Registration during app setup
ServiceContainer.shared.register(APINetworkService() as NetworkService)

Key Benefits and Considerations

Property wrappers excel at eliminating code duplication while maintaining type safety and performance. They provide a clean separation between property behavior and business logic, making your code more maintainable and testable.

The examples shown here demonstrate how property wrappers can handle common patterns like persistent storage, value validation, change observation, and dependency management. Each wrapper encapsulates specific behavior that can be reused across your entire application with minimal overhead.

When designing property wrappers, focus on single responsibilities and clear interfaces. The goal is to hide complexity while providing intuitive, easy-to-use APIs that make your code more expressive and less error-prone.

This article covered four practical property wrapper implementations that address common development challenges in Swift applications. These patterns can serve as building blocks for more sophisticated property wrapper designs tailored to your specific needs.