Categories
Swift

Strictly Typed UserDefaults with Property Wrappers

At last year’s WWDC, Apple introduced Swift 5.1 which features property wrappers. Property wrappers aim to reduce the amount of boilerplate code you have to write for accomplishing common tasks.

One of such tasks is accessing values stored in UserDefaults. In fact, it’s one of the most common use cases for property wrappers: it was originally mentioned in Apple’s keynote back when property wrappers were first introduced to the public.

In this post, I’ll show how you can make your UserDefaults workflow seamless and straightforward with the help of property wrappers, and also cover a couple aspects one might not pay enough attention to.


Property wrappers operate just as their name suggests: they wrap properties, allowing for additional functionality to be attached. If you’re familiar with the concept of opaque data types, you might find some similarities: both hide the concrete details of how values are stored internally, and expose the interface clients can use.

At a more practical level, a property wrapper is just a struct whose declaration is prepended with the @propertyWrapper attribute. (Property wrappers can be classes, too, but for the sake of simplicity I’ll continue referring to them as structs in this post.) In a sense the @propertyWrapper attribute functions as a protocol: it requires the struct to expose the wrappedValue property. wrappedValue is what clients see and get when they address a property that’s wrapped in a property wrapper.

But enough with explanations; let’s see what property wrappers look like in practice. As in the previous post, I’ll use Space Photos as the example. The app stores user preferences, such as whether to download regular- or large-size images—in UserDefaults. Before property wrappers, the code looked messy and bug-prone, with a bunch of string literals identifying objects in the storage, and data types you had to guess.

if UserDefaults.standard.bool(forKey: "preferHighQualityImages") {
    return hdURL
} else {
    return url
}

With property wrappers, the code looks as straightforward as possible, takes advantage of strict typing and name autocompletion, and is less likely to contain typos:

return SPUserDefaults.preferHighQualityImages ? hdURL : url

The architecture I propose is pretty simple. It consists of just one enum and one struct: the struct contains the logic for storing and accessing a single preferences record, and the enum plays the role of a container / registry for your records. Since the former isn’t explicitly used anywhere outside the latter, I designed the struct to be nested inside the container.

The container in this post is named SPUserDefaults because obviously you can’t just name it UserDefaults without introducing name collisions. (SP stands for Space Photos.) You’re free to name it whatever you like.

For now, an empty declaration is enough.

enum SPUserDefaults { }

The more interesting part is what’s inside the Record struct.

import Foundation

extension SPUserDefaults {
    
    struct Record<T> { // 1
        
        // MARK: Properties
        
        private let defaultValue: T
        private let fullKey: String
        
        // MARK: Initializers
        
        init(key: String, default defaultValue: T) {
            self.fullKey = Self.fullKey(for: key) // 2
            self.defaultValue = defaultValue
        }
        
        // MARK: Full Key Generation
        
        private static func fullKey(for shortKey: String) -> String {
            "com.example.MyApp.preferences.\(shortKey)" // 3
        }
        
    }
    
}

Here’s a few notes:

  1. The struct uses generics to bind records to specific types, which makes up for a solution for one big UserDefaults’s flaw. You see, UserDefaults has native support for a few common types, such as bools, strings, and numbers; but whenever you save something else, it’s stored as Any, which means you have to note, guess, or cast types at the time of retrieval;
  2. Here the passed key is transformed to a full key. I find it handy to have UserDefaults records organized into domains like user preferences, service flags, etc.;
  3. I prefer using the reverse domain name notation for all things related to my app. This way I don’t risk confusing the app’s data with some framework’s or the system’s. But you’re free to use any string here. Just make sure it doesn’t change over time.

The struct isn’t a proper property wrapper (pun intended) yet. Now’s the time to change that. First, prepend the declaration with @propertyWrapper:

@propertyWrapper
struct Record<T> {

Doing so will impose a new requirement on the struct: it must have the wrappedValue property. Let’s add one:

var wrappedValue: T { // 1
    get {
        UserDefaults.standard.object(forKey: fullKey) as? T ?? defaultValue // 2
    } set {
        UserDefaults.standard.set(newValue, forKey: fullKey)
    }
}
  1. The variable’s type is T which ensures the variable always maps to the type it was declared with;
  2. Under the hood, all values are stored as Any, and optionally cast to T when retrieved. If the value doesn’t exist in UserDefaults, or if it can’t be converted to T, the default value is used instead.

Let’s now create a Record you can use in other parts of your app. In the SPUserDefaults enum (or in an extension to it), declare a static variable marked with the @Record property wrapper.

extension SPUserDefaults {
    
    @Record(key: "highQualityImages", default: false)
    static var preferHighQualityImages: Bool
    
}

Now, you can use SPUserDefaults.preferHighQualityImages as a regular Bool variable anywhere in the app. That’s all it takes! Can you believe it?


Now, let’s have a look at that noteworthy aspect I mentioned in the beginning. Think of what kinds of values you can store in SPUserDefaults. More specifically, whether they can be optionals. This might surprise you, but they can, and the lack of ? in wrappedValue: T is not an obstacle.

As you might know, optionals in Swift are implemented in the form of the enum Optional that holds actual values. So when you attempt to add a Record with an optional type (e.g. Bool?) to the container, it won’t cause any trouble with T not having a question mark at the end. That’s because the generic type T will be resolved as Optional<Bool>.

I’m drawing your attention to that aspect because being able to store nils in Records has no practical sense, yet comes with a lot of confusion. For example, what is a nil supposed to mean when it’s set as preferHighQualityImages’s value? Is it the same as false? Is it unknown? Should it be replaced with a default value? One can only guess, and having to guess something usually means there’s a design flaw.

With all that said, it seems like a good idea to prevent optionals from being used in Record declarations. Fortunately, it’s a relatively easy thing to do.

init(key: String, default defaultValue: T) {
    self.fullKey = Self.fullKey(for: key)
    self.defaultValue = defaultValue
    
    validateWrappedValueType() // 1
}

private func validateWrappedValueType() {
    if T.self is ExpressibleByNilLiteral.Type { // 2
        assertionFailure("SPUserDefaults.Record's wrappedValue must not be nil") // 3
    }
}
  1. Add a call to the new method to the initializer;
  2. What the validation method does is checks whether the resolved type of Record conforms to the ExpressibleByNilLiteral protocol. It is a special protocol that denotes instances of the type can be initialized with nil (which basically means the type is Optional). Apple discourages use of this protocol for anything else, so it’s safe to rely on it;
  3. If you attempt to add an optional Record to the container, you’ll get a runtime error in debug builds. assertionFailure(_:) doesn’t affect release configurations, so if an optional record slips into a release build somehow, the real users won’t experiences crashes. You may use fatalError(_:) instead to terminate the app in all build configurations.

UserDefaults is just one example of how property wrappers can be used to simplify your work and reduce the amount of boilerplate code in your project. Stay tuned for more on The Swift Bird.

And here’s the complete playground code:

import Foundation

enum SPUserDefaults { }

extension SPUserDefaults {
    
    @Record(key: "highQualityImages", default: false)
    static var preferHighQualityImages: Bool
    
}

extension SPUserDefaults {
    
    @propertyWrapper
    struct Record<T> {
        
        // MARK: Properties
        
        private let defaultValue: T
        private let fullKey: String
        
        // MARK: Initializers
        
        init(key: String, default defaultValue: T) {
            self.fullKey = Self.fullKey(for: key)
            self.defaultValue = defaultValue
            
            validateWrappedValueType()
        }
        
        // MARK: Wrapped Value
        
        var wrappedValue: T {
            get {
                UserDefaults.standard.object(forKey: fullKey) as? T ?? defaultValue
            } set {
                UserDefaults.standard.set(newValue, forKey: fullKey)
            }
        }
        
        // MARK: Type Validation
        
        private func validateWrappedValueType() {
            if T.self is ExpressibleByNilLiteral.Type {
                assertionFailure("SPUserDefaults.Record's wrappedValue must not be nil") //
            }
        }
        
        // MARK: Full Key Generation
        
        private static func fullKey(for shortKey: String) -> String {
            "com.example.MyApp.preferences.\(shortKey)"
        }
        
    }
    
}