DEV Community

kodelit
kodelit

Posted on • Edited on

UserDefaults property wrapper - Issues & Solutions

Swift 5.1, Xcode 11.2

Skip the boring: Solution for property with Optional type | Improved solution form proposal | UPDATE: Conclusions after using in practice

Property wrapper is the new feature in Swift 5.1. There are plenty of articles covering the topic of using this feature for many purposes. One of them is wrapping property around UserDefaults, which means using UserDefaults (UserDefults.standard in most cases, but this is not the only possibility) instead of backing variable for the property.

I do not want to duplicate the topic when there are so many other places where this is described very well:

However, everyone is focusing only on the simplest cases, but no one is speaking about the issues.

What if...

Let's take some example implementation of the property wrapper:

@propertyWrapper public struct UserDefault<T> {
    public let key: String
    public let defaultValue: T
    public var wrappedValue: T {
        get {
            return UserDefaults.standard.object(forKey: key) as? T ?? defaultValue
        }
        set {
            UserDefaults.standard.set(newValue, forKey: key)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

All seams to look good and it works in the most common cases like:

@UserDefault(key: "some_flag", defaultValue: false)
public var someFlag: Bool
Enter fullscreen mode Exit fullscreen mode

Maybe sometimes there will be a need to set also initial value despite the fact that we have the defaultValue, then we have to add following two initializers No, it's a terrible idea, more about it further:

public init(key: String, defaultValue: T) {
    self.key = key
    self.defaultValue = defaultValue
}

// !!!: there was no such initializer in the proposal, it's my terrible idea
public init(wrappedValue: T, key: String, defaultValue: T) {
    self.key = key
    self.defaultValue = defaultValue
    self.wrappedValue = wrappedValue
}
Enter fullscreen mode Exit fullscreen mode

now we can could use it also like this:

@UserDefault(key: "some_flag", defaultValue: false)
public var someFlag: Bool ~~= true~~
Enter fullscreen mode Exit fullscreen mode

But what if the type of the property will be Optional value type?

This generic struct might be adopted in such case. What happens then?
To find out I'm going to use the UserDefaultPropertyWrapper.playground available on GitHub, where:

1. Property wrapper is modified like this:

@propertyWrapper public struct UserDefault<T> {
    public let key: String
    public let defaultValue: T
    public var wrappedValue: T {
        get {
            let udObject = UserDefaults.standard.object(forKey: key)
            let udValue = udObject as? T
            let value = udValue ?? defaultValue
            print("get UDObject:", udObject as Any, "UDValue:", udValue as Any, "defaultValue:", defaultValue, "returned value:", value)
            return value
        }
        set {
            print("set UDValue:", newValue as Any, "for key:", key)
            UserDefaults.standard.set(newValue as Any, forKey: key)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

2. Property is defined as the following:

class Some {
    @UserDefault(key: "optional_flag", defaultValue: false)
    public var optionalFlag: Bool?
}
Enter fullscreen mode Exit fullscreen mode

3. Let's test getter and setter:

let object = Some()

object.optionalFlag
object.optionalFlag = true
object.optionalFlag
object.optionalFlag = nil
Enter fullscreen mode Exit fullscreen mode

Console:

Property Init with key: 'optional_flag', defaultValue: 'Optional(false)'
get UDObject: nil UDValue: Optional(nil) defaultValue: Optional(false) returned value: nil
set UDValue: Optional(true) for key: optional_flag
get UDObject: Optional(1) UDValue: Optional(Optional(true)) defaultValue: Optional(false) returned value: Optional(true)
set UDValue: nil for key: optional_flag
libc++abi.dylib: terminating with uncaught exception of type NSException
Enter fullscreen mode Exit fullscreen mode

This is quite tricky.
First get a fix on the fact that print(...) method used to print console logs unwraps values, so for Optional<Bool>(nil) or if you will Optional<Bool>.none it will print nil, and for Optional<Bool?>(value) will print Optional(value).

As we can see in console log line 2: UDValue is not nil but in fact Optional(nil) which means that it is wrapped twice. It is even more visible in line 4 of the console log above.

It can be simply confirmed by printing:

print(Optional<Bool>(nil))                       // nil
print(Optional<Bool?>.some(Optional<Bool>(nil))) // Optional(nil)
print(Optional<Bool?>.some(Optional<Bool>(true)))// Optional(Optional(true))
Enter fullscreen mode Exit fullscreen mode

Why is that? Because we use the conditional cast on Optional type: as? T where T is Bool? so we do something like this as? Bool? which returns Bool??

So what is happening?

1. The getter

When stored value is nil (simply not set in UserDefaults) we have:

let defaultValue: Bool? = Optional<Bool>.some(false)
let udObject: Bool? = Optional<Bool>.none
let udValue: Bool?? = (Optional<Bool>.none as? Bool?) // Optional<Bool?>.some(Optional<Bool>.none)
let value: Bool? = Optional<Bool?>.some(Optional<Bool>.none) ?? defaultValue // expression returns `Optional<Bool>.none` not `defaultValue`
print("get UDObject:", udObject, "UDValue:", udValue, "defaultValue:", defaultValue, "returned value:", value)
Enter fullscreen mode Exit fullscreen mode

So what we see here?

  1. getter will never return defaultValue if the type of the property T is Optional type
  2. returned value will be nil which is a valid value for Bool? type

The same case is with concrete value
As long as this issue causes only some unexpected behavior, there is a much worse issue.

2. The setter

When we try to set nil to the property with Optional type the setter crashes:

error-on-setter-crush

Long story short this is also caused by the Optional in the Optional, and the same same error occurs if you write:
UserDefaults.standard.set(Optional(Optional<Bool>(nil)), forKey: "optional_flag")
error-on-setter-crush

What we can do?

1. Solution for the property with Optional type.

Simple option and maybe preferred by some people is to use separate wrapper for optional values.

@propertyWrapper
public struct OptionalUserDefault<T> {
    public let key: String
    public var wrappedValue: T? {
        get {
            return UserDefaults.standard.object(forKey: key) as? T
        }
        set {
            UserDefaults.standard.set(newValue, forKey: key)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The solution is not so bad because:

  • distinguishes the case where the value is optional
  • there is no need to define defautlValue because it is not needed since we expect that the value might not be there.

However, it does not guarantee that nobody will use @UserDefault(key:defaultValue:) attribute to a property with Optional type.


That's why we should fix the proposal code.

2. Improved solution form proposal.

There is another solution that allows us to use one wrapper for every mentioned case or at least make it safer.

@propertyWrapper
public struct UserDefault<T> {
    public let key: String
    public let defaultValue: T
    public var wrappedValue: T {
        get {
            let udValue = UserDefaults.standard.object(forKey: key) as? T
            switch (udValue as Any) {
            case Optional<Any>.some(let value):
                return value as! T
            case Optional<Any>.none:
                return defaultValue
            default:
                return udValue ?? defaultValue
            }
        }
        set {
            switch (newValue as Any) {
            case Optional<Any>.some(let value):
                UserDefaults.standard.set(value, forKey: key)
            case Optional<Any>.none:
                UserDefaults.standard.removeObject(forKey: key)
            default:
                UserDefaults.standard.set(newValue, forKey: key)
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

UPDATE: Conclusions after using in practice.

My experience showed that there still is something to improve here.

1. The initial value is a very bad idea

I mean allowing to set initial value during initialization like this:

@UserDefault(key: "some_flag", defaultValue: false)
public var someFlag: Bool = true
Enter fullscreen mode Exit fullscreen mode

thanks to the initializer:

/// Initializer allowing to set initialValue of the property
public init(wrappedValue: T, key: String, defaultValue: T) {
    self.key = key
    self.defaultValue = defaultValue
    self.wrappedValue = wrappedValue
}
Enter fullscreen mode Exit fullscreen mode

The initial value may cause more harm than profits. If we have a property with an initial value, it will override any value previously stored in the UserDefaults under the given key.

Let say we have a user defaults key "some_key"

If somewher in the code you did set value for the key UserDefaults.standard.set(true, forKey: "some_key")

And you have the following class

class SomeSettings {
    @UserDefault(key: "some_key", defaultValue: false)
    var someFlag: Bool = false
}
Enter fullscreen mode Exit fullscreen mode

Then every time the class instance is created, it will cause overriding the previously set value in the UserDefaults with false.

That is why we should drop the idea of the initial value in case of property wrapper for storages like UserDefaults or Keychain.

As long as we have a default value, it is not needed anyway.

2. It's good to restrict allowed types to the set of types supported by Property List format.

Since we know which types are acceptable by the Property List, we can restrict our structs to be used only with the values of selected types.

To do so, we need:

  1. declare working protocol PlistCompatible.
  2. make compatible types conforming to the protocol.

    public protocol PlistCompatible {}
    
    // MARK: - UserDefaults Compatibile Types
    
    extension String: PlistCompatible {}
    extension Int: PlistCompatible {}
    extension Double: PlistCompatible {}
    extension Float: PlistCompatible {}
    extension Bool: PlistCompatible {}
    extension Date: PlistCompatible {}
    extension Data: PlistCompatible {}
    extension Array: PlistCompatible where Element: PlistCompatible {}
    extension Dictionary: PlistCompatible where Key: PlistCompatible, Value: PlistCompatible {}
    
  3. force the generic type T to conform to the protocol

    @propertyWrapper
    public struct UserDefault<T: PlistCompatible> {
    

    Our Wrappers are looking like this:

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

3. It would be handy to be able to utilize the wrapper for custom types

To hadle this case we need to write separate wrappers. These will be storing value of type T conforming to RawRepresentable with restriction that its RawValue is PlistCompatible. Our wrapper requires simple modifications:

@propertyWrapper
public struct WrappedUserDefault<T: RawRepresentable> where T.RawValue: PlistCompatible {
// ...
}
Enter fullscreen mode Exit fullscreen mode

So we will need the following two definitions:

@propertyWrapper
public struct WrappedUserDefault<T: RawRepresentable> where T.RawValue: PlistCompatible {
    public let key: String
    public let defaultValue: T
    public var wrappedValue: T {
        get {
            guard let value = UserDefaults.standard.object(forKey: key) as? T.RawValue else {
                return defaultValue
            }
            return T.init(rawValue: value) ?? defaultValue
        }
        set {
            UserDefaults.standard.set(newValue.rawValue, forKey: key)
        }
    }
}

@propertyWrapper
public struct OptionalWrappedUserDefault<T: RawRepresentable> where T.RawValue: PlistCompatible {
    public let key: String
    public var wrappedValue: T? {
        get {
            guard let value = UserDefaults.standard.object(forKey: key) as? T.RawValue else {
                return nil
            }
            return T.init(rawValue: value)
        }
        set {
            UserDefaults.standard.set(newValue?.rawValue, forKey: key)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Now the only thing we need is to make our custom type to conform to RawRepresentable protocol.

4. What if the key depends on something?

I had the case when the key was depending on some value provided in init(id: String). Key would look like account[\(id)].someFlag

In this case, I needed to set the property key during the initialization of the class and its properties. This is very interesting and possible to implement case but also not so common so since no one is reading... :) Maybe another time.

All files and code are available on the GitHub and free to use. Enjoy.

GitHub logo kodelit / UserDefaultPropertyWrapper

Wrapper for property which value should be stored in `UserDefaults.standard` under the given `key` instead of using backing variable

Top comments (1)

Collapse
 
shawnbaek profile image
Shawn Baek

Thanks for the posting. I struggle to find the solution to get an optional value when setting defaultValue. 👍