Mastering Swift Property Wrappers: A Deep Dive into @propertyWrapper and Projected Values
Discover how to write cleaner, more maintainable iOS code using Swift Property Wrappers. Learn how to build a custom, testable UserDefaults wrapper and master Projected Values ($) in Swift.
What is a Property Wrapper?
Imagine you are developing an iOS game and face a common problem: you have numerous variables, and every time you read or assign data to them, you need to execute standard validation logic. For instance, your game character has attributes like health, energy, and volume. The core business rule is strict: These values must never exceed 100, and they can never drop below 0.
1. The Tedious Way: Without a Property Wrapper
If we didn’t use property wrappers, we would have to write manual validation code for every single variable. Our code would look like a boilerplate nightmare:
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 => 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 => 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 }
}
}
}Imagine doing this for 50 different variables. Not only does your code become cluttered, but if business requirements change tomorrow say, “The new limit is 200 instead of 100” you would have to manually update 50 different places. This is a severe violation of clean code principles.
2. The Preferred Way: Using a Property Wrapper
Instead of duplicating logic, we can extract the if-else constraints from the set block and encapsulate them inside a special container called @Clamped.
Step A → Encapsulating the Logic (The Wrapper): We move our validation logic directly into a custom struct:
@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 limits even during 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 CORE LOGIC IS NOW HERE ---
if newValue > max { value = max }
else if newValue < min { value = min }
else { value = newValue }
}
}
}Step B → Clean Implementation: We can now throw away all those long private var, get, set, and if-else blocks. The inside of our class becomes pristine:
struct Player {
// We invoke all that logic with a single tag!
@Clamped(min: 0, max: 100) var health: Int = 100
@Clamped(min: 0, max: 100) var energy: Int = 50
}Real-World iOS Architecture: Building a Custom UserDefault Wrapper
In a professional environment, we want to avoid the boilerplate of constantly writing to the database, reading from it, remembering string keys, and handling default values. We want the variable to be written to memory the moment we define it.
However, in enterprise-level projects, we must adhere to Testability and Dependency Injection (DI) rules. Therefore, instead of coupling our wrapper directly to UserDefaults.standard, we write a protocol first.
1. Writing a DI-Compliant Wrapper
By using generics, we ensure this wrapper works with any data type. Furthermore, the KeyValueStore protocol allows us to use mock databases when writing Unit Tests, preserving our actual device storage.
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 Encoder/Decoder 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 Native (Primitive) first
if let value = container.object(forKey: key) as? T {
return value
}
// 2. If it 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 (Handling nils in generics is tricky; we use a helper)
if let optional = newValue as? AnyOptional, optional.isNil {
container.set(nil, forKey: key)
return
}
// 2. Are they 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's 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 protocol to determine if a Generic type 'T' is 'nil'
private protocol AnyOptional { var isNil: Bool { get } }
extension Optional: AnyOptional { var isNil: Bool { self == nil } }2. Implementation in a Real Project (Storage Manager)
The days of writing UserDefaults.standard.set are over. We can now cleanly gather all our configurations in 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 this structure achieves:
- @UserDefault: “Don’t just hold this in RAM; write it to persistent storage (Container) using the string I provided as the key.”
- static var: “I won’t deal with keys or set/get functions in the rest of the code. When I call Storage.hasSeenOnboarding, just give me true or false directly.”
Understanding Projected Values in Swift
When a Property Wrapper (like @State, @UserDefault, etc.) is created, Swift gives us access to two different pieces of data through that single variable:
- WrappedValue (Normal Access): This is the actual value you get when you type the variable’s name (e.g., String, Int).
- ProjectedValue (Access via $): When you place a $ in front of the variable, you get a secondary (helper) value determined by the designer of the wrapper.
The Clearest Example: SwiftUI and State
Have you ever wondered why we put a $ in front of a variable when creating a TextField in SwiftUI?
struct LoginView: View {
@State private var username: String = "Ugur"
var body: some View {
VStack {
// A) Normal Reading (wrappedValue) => We just need the 'String' value. So, NO $.
Text("Welcome, \(username)")
// B) Writing / Binding (projectedValue) => The TextField needs to "change" that variable.
// We don't need a String; we need a 'Binding<String>'. So, YES $.
TextField("Username", text: $username)
}
}
}When we write username: The @State wrapper returns its wrappedValue. We get a simple String (“ugur”).
When we write $username: The @State wrapper returns its projectedValue. SwiftUI engineers designed this to return a Binding. This allows the TextField to both read and update the variable.
Applying Projected Values to Our Custom UserDefault Wrapper
Let’s apply this logic to our custom wrapper. Just as the $sign provides “Binding (Write Ability)” in SwiftUI, we can use the$ sign to provide “Management Abilities” (like deleting, fetching the key, etc.) in our wrapper.
To do this, we simply tell the projectedValue property inside the wrapper to return the wrapper itself (self).
// ... (Previous UserDefault code remains the same) ...
// This grants access to the wrapper's helper methods
public var projectedValue: UserDefault<T> {
return self
}
// MARK: - Extra Capabilities -> Accessible ONLY via $ (projectedValue)
public func remove() {
container.set(nil, forKey: key)
}
}How to Use It: Now we can interact with our variable in two distinct ways:
enum Storage {
@UserDefault(key: "auth_token", defaultValue: "")
static var token: String
}
// 1. Normal Usage (wrappedValue)
Storage.token = "12345ABC"
print(Storage.token) // Output: "12345ABC"
// 2. Projected Usage (projectedValue) via $
Storage.$token.remove()
print(Storage.token) // Output: "" (Returns to default value)Summary:
- variableName: Reaches the data itself (for reading/writing).
- $variableName: Reaches the extra features provided by the enclosing structure (for binding, deleting, resetting, etc.).

One Comment