Abstract 3D illustration of Swift Property Wrappers concept showing @propertyWrapper and Projected Values in iOS development

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:

  1. WrappedValue (Normal Access): This is the actual value you get when you type the variable’s name (e.g., String, Int).
  2. 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.).

Similar Posts

One Comment

Leave a Reply

Your email address will not be published. Required fields are marked *