Property Wrappers With Generic (Optional) User Defaults

Viewed 1538

With due reference:

https://github.com/apple/swift-evolution/blob/master/proposals/0258-property-wrappers.md#user-defaults

We've started to use property wrappers for the UserDefaults, it works seamlessly with non-optional properties.

However, setting nil of an optional property crashes with:

[User Defaults] Attempt to set a non-property-list object as an NSUserDefaults/CFPreferences value for key "someKeyThatWeSet"

Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: 'Attempt to insert non-property list object null for key "someKeyThatWeSet"'

The code below can be tested on Playground directly:

@propertyWrapper
struct C2AppProperty<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)
        }
    }
}

struct C2User {
    @C2AppProperty("userID", defaultValue: nil)
    public static var publicUserID: String?
}

print(C2User.publicUserID)
C2User.publicUserID = "edusta"
print(C2User.publicUserID)
C2User.publicUserID = nil
print(C2User.publicUserID)

Expected:

nil

Optional<"edusta">

nil

Found:

nil

Optional<"edusta">

libc++abi.dylib: terminating with uncaught exception of type NSException

What I've tried so far:

set {
    // Comparing non-optional value of type 'T' to nil always returns false.
    if newValue == nil {
        UserDefaults.standard.removeObject(forKey: combinedKey)
    } else {
        UserDefaults.standard.set(newValue, forKey: combinedKey)
    }
}

What kind of a check is needed here to catch that newValue is nil? Or an Optional<nil>?

3 Answers

This code works for me:

@propertyWrapper
struct UserDefault<T> {
    private let key: String
    private let defaultValue: T
    private let userDefaults: UserDefaults

    init(_ key: String, defaultValue: T, userDefaults: UserDefaults = .standard) {
        self.key = key
        self.defaultValue = defaultValue
        self.userDefaults = userDefaults
    }

    var wrappedValue: T {
        get {
            guard let value = userDefaults.object(forKey: key) else {
                return defaultValue
            }

            return value as? T ?? defaultValue
        }
        set {
            if let value = newValue as? OptionalProtocol, value.isNil() {
                userDefaults.removeObject(forKey: key)
            } else {
                userDefaults.set(newValue, forKey: key)
            }
        }
    }
}

fileprivate protocol OptionalProtocol {
    func isNil() -> Bool
}

extension Optional : OptionalProtocol {
    func isNil() -> Bool {
        return self == nil
    }
}

Thanks to Michcio for providing the solution,

If we read property very frequently, the following code may have better performance. Use storage property can save value in memory, it won't be read value from disk every time.

@propertyWrapper
struct ReadOptimizeStorage<T> {
    private let key: String
    private let defaultValue: T
    private let userDefaults: UserDefaults

    init(key: String, defaultValue: T, userDefaults: UserDefaults = .standard) {
        self.key = key
        self.defaultValue = defaultValue
        self.userDefaults = userDefaults
        
        wrappedValue = userDefaults.object(forKey: key) as? T ?? defaultValue
     }

    var wrappedValue: T {
        didSet {
            if let value =  wrappedValue as? OptionalProtocol, value.isNil() {
                userDefaults.removeObject(forKey: key)
            } else {
                userDefaults.set(wrappedValue, forKey: key)
            }
        }
    }
}


fileprivate protocol OptionalProtocol {
    func isNil() -> Bool
}

extension Optional : OptionalProtocol {
    func isNil() -> Bool {
        return self == nil
    }
}

I made a tiny spm package inspired by the ripples answers above:

https://github.com/mezhevikin/userdefault.git

import UserDefault

extension UserDefaults {
    
    // Optional property
    @UserDefault("login", .standard)
    static var login: String?
    
    // Property with default value
    @UserDefault("isDarkMode", false)
    static var isDarkMode: Bool
}
UserDefaults.login = "mezhevikin"
UserDefaults.isDarkMode = true
Related