There are various ways we can improve our use of Foundation's UserDefaults API; let's explore a few of them here.

The Problem

Because the UserDefaults framework relies on string keys, it can be extremely prone to programming error. One only needs to mistype "highscore" as "highscre" and your whole application might break - a programmer's nightmare.

UserDefaults.standard.integer(forKey: "highscore")
UserDefaults.standard.integer(forKey: "highscre") // 0 returned as default

To make matters worse, the values are never returned as optionals. Instead, default values (such as 0) are returned. This can make debugging even harder.

Solution No. 1 - Enum Keys

The simplest solution of the lot is as follows:

enum MyKeys: String {
    case highscore
    case characterName
}

We create an enum to hold all the keys we will use as UserDefaults. Immediately, we have a more type-safe call-site:

UserDefaults.standard.integer(forKey: MyKeys.highscore.rawValue)

If you always use the MyKeys struct to provide the keys to UserDefaults, you will never run into problems with having the wrong key again as the complier will not a misspelling of a variable!

However, most swift programmers should see the issues with the above code:

  • The call-site is quite verbose - we have to list UserDefaults as well as MyKeys.

  • There is still no true type safety - we could call the following and the compiler wouldn't throw an error even though we have stored an Int at "highscore":

UserDefaults.standard.bool(forKey: MyKeys.highscore.rawValue)

Solution No. 2 - Custom Read/Write Methods

Instead of using only one enum to hold the keys, we could use multiple:

enum MyIntKeys: String {
    case highscore
}

enum MyStringKeys: String {
    case characterName
}

By separating the keys for two different data types, we can create a completely type-safe wrapper as follows:

struct PersistenceContainer {
    private init() { }

    func readInt(_ key: MyIntKeys) -> Int {
        return UserDefaults.standard.integer(forKey: key.rawValue)
    }

    func readString(_ key: MyStringKeys) -> String {
        return UserDefaults.standard.string(forKey: key.rawValue)
    }

    // Add writeInt and writeString methods in a similar style
}

This is a dramatic improvement. Now, we are restricted to calling the correct method that returns the correct type for each key. For example:

PersistenceContainer.readInt(.highscore)    // OK
PersistenceContainer.readString(.highscore) // Does not compile

Look at how much cleaner the call sites are now, as well. In addition, if we ever wanted to swap our key-value store, we only have to do so in one file.

However, a new method needs to be written for each data type we want to save, which could be cumbersome for some apps that lean into the UserDefaults framework heavily.

Solution No. 3 - Alternative Syntax

We could put the reading and writing code within the enums instead, if you prefer.

enum MyIntKeys: String {
    case highscore

    func read() -> Int {
        return UserDefaults.standard.integer(forKey: self.rawValue)
    }

    // Sim. for write method
}

In this case, the read() method is called on the enum case directly. This changes the feel of the call-site drastically:

MyIntKeys.highscore.read()

This approach is not really an improvement on solution number 2, but rather just an alternative approach.

Up until now, the techniques have worked wonderfully for imperative programming. Declaritive programming with SwiftUI and Combine, however, means we need to change our strategy. Now, we need to provide a publisher so subscribers can watch for changes.

Solution No. 4 - Property Wrappers

The simplest on the list: use the new @AppStorage or @SceneStorage property wrappers.

@AppStorage("highscore") var highscore: Bool?

Or, using our above solution as well:

@AppStorage(MyKeys.highscore) var highschore: Bool?

Of course, we could provide a default value instead of the optional if we wanted. A custom @UserDefaultsVariable property wrapper could also be an option. I haven't included one here, since use of property wrappers outside of SwiftUI is still limited.

Solution No. 5 - Custom Wrapper Class

Not everyone has the ability to work with SwiftUI and iOS 14, however, so what about an iOS 13 compatible, combine solution? Here's one I made.

Firstly, we make a protocol to identify the types we can save:

protocol DefaultsSavable { }extension Int: DefaultsSavable { }

We can then make a generic class to track the saving and reading of the value:

class Key<T: DefaultsSavable & Equatable>: ObservableObject {
    
    // Variables    
    var keyString: String   
    public let objectWillChange = ObservableObjectPublisher()    
    public var value: T? {        
        UserDefaults.standard.value(forKey: keyString) as? T    
    }    
    
    // Methods    
    public func set(_ item: T) { 
        if item != value {
            UserDefaults.standard.set(item, forKey: keyString)
            objectWillChange.send()
        }
    }    
    
    // Initialiser    
    public init(_ keyString: String) {
        self.keyString = keyString
    }
}

Of course, we could make alternative versions of this with a default value provided instead of an optional, if we wanted. The best way to use this class, I believe, is to keep them all together in a struct like this:

struct DefaultsContainer {
    private init() { }
    static let highscore = Key<Int>("highscore")
}

Then, we can subscribe to change in the obsersable object through the @ObservedObject property wrapper or manually through objectWillChange.

NOTE: The publishers of this class will only work if all use of UserDefaults is through that class.

Conclusion

Although using UserDefaults may seem trivial at first, it's important to remember that there are numerous ways to access it in a safer (and swiftier) manner.

Happy coding!