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:
- 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 asAny
, which means you have to note, guess, or cast types at the time of retrieval; - 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.; - 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)
}
}
- The variable’s type is
T
which ensures the variable always maps to the type it was declared with; - Under the hood, all values are stored as
Any
, and optionally cast toT
when retrieved. If the value doesn’t exist inUserDefaults
, or if it can’t be converted toT
, 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 nil
s in Record
s 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
}
}
- Add a call to the new method to the initializer;
- What the validation method does is checks whether the resolved type of
Record
conforms to theExpressibleByNilLiteral
protocol. It is a special protocol that denotes instances of the type can be initialized withnil
(which basically means the type isOptional
). Apple discourages use of this protocol for anything else, so it’s safe to rely on it; - 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 usefatalError(_:)
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)"
}
}
}