Strongly Typed UserDefaults

Episode #421 | 12 minutes | published on December 12, 2019 | Uses Xcode-11.2.1, Swift-5.1
Subscribers Only
UserDefaults is quite a handy class for storing user user preferences and lightweight data. However the data is keyed by strings and there's no enforcement of any schema or validation of the data you put in it. In this episode we will look at a technique for making strongly typed access to data in UserDefaults so that we can avoid mistakes when typing the key name or the type of data intended to be written to that key.

Note: The demo in this has an error in it. You'll need to use a rawValue when setting the .theme key, which means you'll have to change the type to String. I've updated the source code to reflect this, but you can see Episode 422 for a slightly different approach.

Episode Links

We can store preferences in UserDefaults using string keys. This will allow us to store settings and other lightweight data that will be persisted across app launches.

let theme = UserDefaults.standard.string(forKey: "theme")
return theme == "dark"

This is great, however there are 2 ways we can mess up here. If we mispell the key, we'll be reading from the wrong place (or writing to the wrong place). Additionally, the value here is a string, but there might be a number there, or a boolean, or something else.

Let's start by eliminating the first issue: string keys. Any time you are repeating the same string in multiple places you want to start thinking about defining that once and using that everywhere.

We could use a constant, but we can get some nice code completion if we leverage Swift's type system

extension UserDefaults {
   struct Key {
       fileprivate let name: String

       init(name: String) {
           self.name = name
       }
   }
   // ...
}

This gives us a type that will wrap the string for us. We've marked the name fileprivate so that we can read it in other methods in this file, but it will be private to outside callers.

Next we can add some methods to read, write, and remove values using this new Key type:

    func set(_ value: Any, for key: Key) {
        set(value, forKey: key.name)
    }

    func value(for key: Key) -> Any? {
        return value(forKey: key.name) as? V
    }

    func removeValue(for key: Key) {
        removeObject(forKey: key.name)
    }

With these in place we can go back to our view controller (or wherever we want to place our app's settings) and define the key:

extension UserDefaults.Key {
    static let theme = UserDefaults.Key(name: "theme")
}

Usage becomes much nicer:

// get the theme
let theme: String = UserDefaults.standard.value(for: .theme)

// set the theme
UserDefaults.standard.set("dark", forKey: .theme)

This is nicer, but there's nothing to stop you from sending a URL or a Date here. Or even "hamburger". It would be nice if we could constrain the value used underneath this key...

extension UserDefaults {
    struct Key<Value> {
        fileprivate var name: String

        init(name: String) {
            self.name = name
        }
    }

    // ...
}

Here we've just added a generic paramter to Key. This breaks the methods we wrote so we have to specify the Value. We can do that by making the methods themselves generic:

    func set<V>(_ value: V, for key: Key<V>) {
        set(value, forKey: key.name)
    }

This means that whatever type V is (which can be defined by the callsite) it will constrain our key and value to match.

The other methods can have a similar treatment:

    func value<V>(for key: Key<V>) -> V? {
        return value(forKey: key.name) as? V
    }

    func removeValue<V>(for key: Key<V>) {
        removeObject(forKey: key.name)
    }

Now we can change our theme key to be constrained by the type of data it should be:

extension UserDefaults.Key where Value == String {
    static let theme = UserDefaults.Key<String>(name: "theme")
}

Now it is only possible to use the intended data type when storing with these new methods.

Usage becomes a lot nicer too!

@IBAction func themeChanged(_ sender: UISwitch) {
    let theme: Theme = sender.isOn ? .light : .dark
    UserDefaults.standard.set(theme.rawValue, for: .theme)
    updateForTheme()
}
let isDark = UserDefaults.standard.value(for: .theme) == Theme.dark.rawValue

Pretty nice!

My thanks to Daniel Tull who's blog post inspired this episode.

blog comments powered by Disqus