Removing Strings as keys in User Defaults

Mon Apr 26, 2021


I haven’t written anything in nearly a year. Pandemic fatigue is painfully present. Endless time at home and zero motivation. So please bear with me as I try to squeeze out a short article about a tiny piece of code that I’m proud of. The first bit of code I’ve written that I actually like in quite a while.

The setup

User defaults. Love it or hate it, it’s used everywhere. I’ve rewritten UserDefaultsManagers all over the place, each time thinking “this one will be better”. Now there’s not much to improve on when you first look. I mean if we think about an interface for a User Defaults wrapper it would look something like:

protocol UserDefaultsManagerInterface {
    func readValue<T>(for key: String) -> String
    func writeValue<T>(_ value: T, for key: String)
    func deleteValue(for key: String)
}

Actually it wouldn’t look like that, it would be that. I’ve used it all over the place previously, and it’s served me just fine.

The problem

My perpetual annoyance isn’t with my wrapper, it’s with what comes next. Some inevitable Storage object. We’ve all written it. It’s a big class or struct with all the items we want to store in UserDefaults. I tend to use the getter and setter of the variables to read and write to storage, and it will likely always be that way. But the worst part is the list of “Keys”, the identifiers for all the objects we’re storing.

class AppStorage {

    private struct Keys {
        static let languageCodeSelectedKey = "languageCodeSelected"
    }

    private let userDefaultsManager: UserDefaultsManagerInterface

    init(userDefaultsManager: UserDefaultsManagerInterface = UserDefaultsManager()) {
        self.userDefaultsManager = userDefaultsManager
    }
		
    var languageCodeSelected: String {
        get { userDefaultsManager.readValue(for: Keys.languageCodeSelectedKey) }
        set { userDefaultsManager.writeValue(newValue, for: Keys.languageCodeSelectedKey) }
    }

}

It starts out ok, but as it grows, the list of keys grows with it. And it gets ugly as different developers chime in and switch from camel case to snake case, prefix with bundle ID vs post fix with bundle ID (please don’t do that second one ever). Furthermore, it’s error-prone. Anywhere we have these keys defined as strings could easily lead to a typo and incorrect data being accessed, or deleted.

The solution

KeyPaths.

KeyPaths will give us an added level of safety to ensure that we aren’t going to try and read from User Defaults with a key that has a typo in it because of human error. No, instead we’re going to use the variable name of the item we’re storing in AppStorage to act as the key.

We could get the key path of the variable within it using the old ObjC way of #keyPath, but it would be nicer to use the newer backslash notation. The only issue is, that doesn’t return us a string, instead it gives us a ReferenceWritableKeyPath, so that’s not immediately much use to us. But if we stick with it and take it a step further, we learn that we can use NSExpression to get the string from the key path with:

NSExpression(forKeyPath: \AppStorage.languageCodeSelected).keyPath

This. Is. Not great. Why would we be putting that into our getters and setters? We’re just going to pollute them with verbosity. How about instead, we tidy this bit away into an extension like so:

extension KeyPath {
    static prefix func ~ (value: KeyPath) -> String {
        NSExpression(forKeyPath: value).keyPath
    }
}

Here I’ve just wrapped this logic in a new custom prefix operator. I’ve used ~, but you can use whatever you like. If we go back to our old languageCodeSelected variable from above, we can update it to look like this:

var languageCodeSelected: String {
    get { userDefaultsManager.readValue(for: ~\AppStorage.languageCodeSelected) }
    set { userDefaultsManager.writeValue(newValue, for: ~\AppStorage.languageCodeSelected) }
}

We can then also get rid of our Keys struct at the top because it’s no longer needed as we’re using the variable names themselves as the keys. Furthermore, if we make a typo when writing ~\AppStorage.languageCodeSelected, the compiler will complain to us.

It doesn’t clean up the whole thing. I mean, you will still likely end up with a large AppStorage class unless you break it down into sections, but it does as least get rid of a list of keys as strings and improve safety.

Using a new prefix function with ~ is just an example here


Spot something wrong? Let me know on Twitter


back · twitter · github · who is matt? · writing · projects · home