Reading Time: 3 minutes

Storing Custom Data Types in UserDefaults Using Property Wrappers in Swift

Property Wrappers in Swift are awesome.

One of the “classic” use cases for them is storing data. And it can be dead simple, especially when it comes to using UserDefaults. It can simplify your code down to something like @UserDefault var x = 0. I am not going to talk about how to write a simple UserDefaults wrapper here, because it has been done hundreds of times (check out this article by Paul Hudson if that’s what you’re looking for).

But let’s face it, UserDefaults can be a pain when working with more complex data types. You might need to provide custom encoding/decoding mechanisms, and your little property wrapper will be no help in that case.

Here I try to provide two ways that I deal with the issue in my projects.

Option 1: Codable

The easiest scenario is when your data type actually conforms to Codable. In this case, we can abstract away the encoding/decoding process and end up with the same straightforward declaration.

@propertyWrapper
struct CodableUserDefault<T: Codable> {
    let key: String
    let defaultValue: T

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

    var wrappedValue: T {
        get {
            if let json = UserDefaults.standard.data(forKey: key),
               let object = try? JSONDecoder().decode(T.self, from: json) {
                return object
            }
            return defaultValue
        }
        set {
            if let json = try? JSONEncoder().encode(newValue) {
                UserDefaults.standard.set(json, forKey: key)
            }
            
        }
    }
}

Which means that we won’t ever have to supply any other information in our initialization:

@CodableUserDefault(“dict”, defaultValue: [0: 0])
private var dict: [Int: Int]

Pretty easy, right? But what if… our data doesn’t conform to Codable, and we either can’t or don’t want to modify our data types?

Option 2. Custom Encoder / Decoder

We can solve this with closures. In this case, our property wrapper will use the closures we provide to convert between the stored type and the actual type on demand. Again, this is a great way to abstract our encoding/decoding logic, but we do need to provide some information.

This way of doing things actually allows us to store any data type in UserDefaults — a problem that would otherwise cause a lot of headache — and boilerplate.

Here’s what I came up with.

@propertyWrapper
struct EncodedUserDefault<OriginalType, StoredType> {

    let key: String
    let defaultValue: OriginalType
    let decoder: ((StoredType) -> OriginalType?)
    let encoder: ((OriginalType) -> StoredType)

    init(_ key: String,
         defaultValue: OriginalType,
         decoder: @escaping ((StoredType) -> OriginalType),
         encoder: @escaping ((OriginalType) -> StoredType)) {
        self.key = key
        self.defaultValue = defaultValue
        self.decoder = decoder
        self.encoder = encoder
    }

    var wrappedValue: OriginalType {
        get {
            if let encodedObject = UserDefaults.standard.object(forKey: key) as? StoredType {
                return decoder(encodedObject) ?? defaultValue
            }
            return defaultValue
        }
        set {
            UserDefaults.standard.set(encoder(newValue), forKey: key)
        }
    }
}

And indeed we will have to supply a bit of extra information when we initialize a property as well. Something like this:

@EncodedUserDefault<Prefs, Int>(
  "prefs",
  defaultValue: .normal,
   decoder: { data in
    return (Prefs(rawValue: data) ?? .normal)
  }, encoder: { object in
    return object.rawValue
}) var prefs: Prefs

In this case, our custom struct of type Prefs gets converted to Int to be stored, and we control the whole process, meaning that we can customize and change anything at any time without writing any more code than needed.

Happy wrapping!

Tagged with:

Comments