Episode #422

Storing Custom Data Types in User Defaults

16 minutes
Published on December 19, 2019

This video is only available to subscribers. Get access to this video and 586 others.

In the last episode we had a bit of an issue... I was trying to store an enum value in UserDefaults, thinking it would automatically use the backing rawValue to store and load the value. Unfortunately this doesn't work. In this episode we fix this issue and extend our solution to also accommodate other data types by leveraging Codable and a custom protocol.

Episode Links

In last week's episode there was a bug when saving a custom enum value Theme. Because this was a backed by a String value, I had incorrectly assumed that it would convert this value for us. Unfortunately this is not the case and it just crashes.

So let's fix that and extend our solution to handle even more custom types of data.

In our extension, let's add a new method that can handle RawRepresentable enums:

func set<S : RawRepresentable>(_ value: S, for key: Key<S>) where S.RawValue == String {
    set(value.rawValue, forKey: key.name)
}

Here we're adding another set method that will be constrained to RawRepresentable types, but only those whose RawValue backing type is String. This is a really powerful feature of Swift's generics.

We also need to do this on the retrieval side:

func value<S : RawRepresentable>(for key: Key<S>) -> S? where S.RawValue == String {
    if let rawValue = value(forKey: key.name) as? String {
        return S(rawValue: rawValue)
    }
    return nil
}

This gets our example working again, as we are free to use Theme as a data type we can store.

But what about something more complex?

Say we had a user struct:

struct User : Codable {
    let id: Int
    let name: String
}

Since this is Codable, it can easily be serialized to & from Data, which is a supported type we can store. So let's add support for Codable types.

We have to be very careful here, however. Other types are also Codable, such as String and Int, and we want those to use their existing methods for storing in UserDefaults, we don't want to convert them to Data. Doing so would be unnecessary and also probably break any existing data store under those keys.

Instead we need to create a marker protocol we can use to attach our behavior to.

protocol UserDefaultsSerializable : Codable {}

struct User : UserDefaultsSerializable {
    // ...
}

Now we can add support for types that conform to this protocol and avoid the issue of String, Int and other supported types getting caught up in this new method.

func set<C : UserDefaultsSerializable>(_ value: C, for key: Key<C>) {
    do {
        let encoder = JSONEncoder()
        let data = try encoder.encode(value)
        set(data, forKey: key.name)
    catch { 
        // print error
    }
}

Retrieving this data is similar:

func value<C : UserDefaultsSerializable>(for key: Key<C>) -> C? {
    do {
        let decoder = JSONDecoder()
        if let data = value(forKey: key.name) as? Data? {
            return try decoder.decode(C.self, from: data)
        } else {
            return nil
        }
    } catch {
        // print error
        return nil
    }
}

With these two methods added we can now save and fetch any Codable type just by marking the type as UserDefaultsSerializable.

To use it, it looks like this:

if let currentUser: User = UserDefaults.standard.value(for: .currentUser) {
    // ..
}

This episode uses Xcode 11.3, Swift 5.1.