Mastering Swift Property Wrappers for Clean Code
We are building an iOS project and we hit a common wall. We have dozens of variables. Every time we assign data to them or read from them, we want to step in and execute standardized logic.
Let us assume we are coding a game character. This character has attributes like health, energy, and volume.
Our strict business rule is simple: These values must never exceed 100, and they must never drop below 0.
1. The Exhausting Legacy Method (Without @propertyWrapper)
If we did not utilize modern Swift features, we would be forced to sit down and write manual validation code for every single variable. Our codebase would devolve into this:
class Player {
// 1. For Health:
private var _health: Int = 100
var health: Int {
get { return _health }
set {
if newValue > 100 { _health = 100 }
else if newValue < 0 { _health = 0 }
else { _health = newValue }
}
}
// 2. For Energy => MASSIVE CODE DUPLICATION!
private var _energy: Int = 100
var energy: Int {
get { return _energy }
set {
if newValue > 100 { _energy = 100 }
else if newValue < 0 { _energy = 0 }
else { _energy = newValue }
}
}
// 3. For Volume => MASSIVE CODE DUPLICATION!
private var _volume: Int = 50
var volume: Int {
get { return _volume }
set {
if newValue > 100 { _volume = 100 }
else if newValue < 0 { _volume = 0 }
else { _volume = newValue }
}
}
}
Now imagine doing this for 50 different variables.
- First, our codebase becomes incredibly polluted.
- Second, if product requirements change tomorrow and the team says, “The limit should be 200 instead of 100,” we have to manually sit down and refactor 50 different locations.
In software architecture, this is a scenario we absolutely hate.
2. The Clean Code Method (Using @propertyWrapper)
In modern Swift, we say: Let us extract that repeating if else logic from the set block, strip it out completely, and encapsulate it inside a dedicated container named @Clamped.
If you are setting up an iOS static framework architecture, understanding this type of data isolation is crucial for keeping your modules clean.
Step A: Moving Logic into the Wrapper
We bring our boundary logic directly into a reusable struct exactly as it is:
@propertyWrapper
struct Clamped {
private var value: Int
private let min: Int
private let max: Int
init(wrappedValue: Int, min: Int, max: Int) {
self.min = min
self.max = max
// Enforcing boundaries right at initialization
if wrappedValue > max { self.value = max }
else if wrappedValue < min { self.value = min }
else { self.value = wrappedValue }
}
var wrappedValue: Int {
get { return value }
set {
// --- OUR BUSINESS LOGIC IS NOW CENTRALIZED HERE ---
if newValue > max { value = max }
else if newValue < min { value = min }
else { value = newValue }
}
}
}
Step B: The Clean Implementation
We are now ready to implement. We can throw away all those long private var _volume, get, set, and if else blocks we wrote earlier. Consequently, the inside of our Class becomes impeccably clean:
struct Player {
// We invoke all the architectural logic with a single tag!
@Clamped(min: 0, max: 100) var health: Int = 100
@Clamped(min: 0, max: 100) var energy: Int = 50
}
Industry Use Cases: The @UserDefault Wrapper
In a real enterprise project, we want to skip the manual labor. We do not want to deal with writing to the database, reading from it, tracking string keys, or handling default values constantly. The moment we define a variable, it should be written to memory.
However, doing this professionally requires strict adherence to Testability and Dependency Injection (DI) rules. If you are serious about managing dependencies and IoC in Swift, you know we cannot tightly couple our wrapper directly to UserDefaults. First, we write a protocol.
1. Writing a DI-Compliant Wrapper
By utilizing Generics (<T>), we ensure our wrapper works with any data type. Furthermore, the KeyValueStore protocol allows us to use Mock databases while testing this code without corrupting the actual device memory.
import Foundation
// Abstraction (DI Contract)
public protocol KeyValueStore {
func object(forKey defaultName: String) -> Any?
func set(_ value: Any?, forKey defaultName: String)
}
extension UserDefaults: KeyValueStore {}
@propertyWrapper
public struct UserDefault<T: Codable> {
let key: String
let defaultValue: T
let container: KeyValueStore
// Making encoders static for performance (Prevents recreation)
private static let encoder = JSONEncoder()
private static let decoder = JSONDecoder()
public init(key: String, defaultValue: T, container: KeyValueStore = UserDefaults.standard) {
self.key = key
self.defaultValue = defaultValue
self.container = container
}
public var wrappedValue: T {
get {
// 1. Try reading as a Native (Primitive) type first
if let value = container.object(forKey: key) as? T {
return value
}
// 2. If that fails, fetch as Data and attempt to Decode (Custom Object)
guard let data = container.object(forKey: key) as? Data else {
return defaultValue
}
do {
let value = try Self.decoder.decode(T.self, from: data)
return value
} catch {
#if DEBUG
print("UserDefault Decode Error for key: \(key) -> \(error)")
#endif
return defaultValue
}
}
set {
// 1. Nil check (Assigning nil in Generics is tricky, we use a helper)
if let optional = newValue as? AnyOptional, optional.isNil {
container.set(nil, forKey: key)
return
}
// 2. Are these natively storable types? (Date and Data included!)
if newValue is Int || newValue is String || newValue is Double || newValue is Bool || newValue is Date || newValue is Data {
container.set(newValue, forKey: key)
} else {
// 3. If it is a Custom Object, Encode it
do {
let data = try Self.encoder.encode(newValue)
container.set(data, forKey: key)
} catch {
#if DEBUG
print("UserDefault Encode Error for key: \(key) -> \(error)")
#endif
}
}
}
}
}
// Helper used to understand if a generic type T is 'nil'
private protocol AnyOptional { var isNil: Bool { get } }
extension Optional: AnyOptional { var isNil: Bool { self == nil } }
2. Real-World Application (UserManager)
The era of writing UserDefaults.standard.set(...) everywhere is over. We gather all settings cleanly into a single Storage or UserManager file:
enum Storage {
@UserDefault(key: "has_seen_onboarding", defaultValue: false)
static var hasSeenOnboarding: Bool
@UserDefault(key: "auth_token", defaultValue: "")
static var token: String
@UserDefault(key: "app_theme", defaultValue: "light")
static var theme: String
}
What exactly are we declaring with this structure?
By writing our code this way, we are giving the system two very distinct commands:
@UserDefault(...): “Do not hold this data in RAM. Write it directly to persistent memory (the Container). And for the key, use the exact string I provided here.”static var ... : Bool: “I refuse to deal with manual keys or repetitiveget/setfunctions in the rest of my codebase. When I callStorage.hasSeenOnboarding, just hand me a directtrueorfalse.”
