A UserDefaults sugar syntax
People who have read Swift by Sundel’s Combining dynamic member lookup with key paths post must have tasted the syntax sweetness from the combo. Below is a use case when my teammate and I wanted to create a helper for user default.
There are two things we wanted to do:
- Make the access super easy. For example, we would like to turn UserDefaults.standard.string(fromKey: “someKey”) into something much simpler like someAccessorHelper.someKey
- An easy way to define the keys for accessing UserDefaults
It seems like a typical dynamicMemberLookup case. Let’s see how we can do it. Below is a part of the class which acts as the accessing interface:
@dynamicMemberLookup
class SchematicUserDefault<Schema: UserDefaultSchema> {
let userDefault: UserDefaults
init(userDefault: UserDefaults = .standard) {
self.userDefault = userDefault
}
subscript<T>(dynamicMember keyPath: KeyPath<Schema, T>) -> T? {
get {
// switch the T.self and decide which UserDefault getter to access
}
set {
// Switch the T.self and decide which setter of UserDefault for `newValue`
// Remove if newValue is nil
}
}
}
This is the typical approach:
use dynamicMemberLookup to enable the dot access, and the KeyPath will help provide auto-complete hints. It fulfilled our first requirement without much effort!
However, the next step will be to define the Schema which contain keys for UserDefaults access.
Defining key constants is easy with enum, but the data type relative to the key always needed a switch map. Here we try to do the magic with a simple struct instead:
struct SomeSchema: Codable {
let someString: String
let someInt: Int
let someFloat: Float
}
Combining with SchematicUserDefault, this approach used KeyPath of SomeSchema to define what and how data can be accessed by the instance of SchematicUserDefault<SomeSchema> in a clean and simple format. However, one thing that is still bothering us is UserDefaults only takes String as key, and KeyPath seems to keep its nature away from its weak-type-predecessor #keyPath in Objective-C. As a result, the conversion from KeyPath to String is still a painful process.
UsingString.init(reflecting:) is the most straightforward way, but the result of it is not guaranteed. The only safe but non-hard code way I can provide so far is as below:
protocol UserDefaultSchema {
static func key<T>(forKeyPath keyPath: KeyPath<Self, T>) -> String
}
extension UserDefaultSchema where Self: Codable {
static func key(_ codingKey: CodingKey) -> String {
"(Self.self)\(codingKey.stringValue)"
}
}
extension SomeSchema: UserDefaultSchema {
static func key<T>(forKeyPath keyPath: KeyPath<Self, T>) -> String {
switch keyPath {
case .someString:
return key(CodingKeys.someString)
case .someInt:
return key(CodingKeys.someInt)
case .someFloat:
return key(CodingKeys.someFloat)
default:
fatalError("Key not defined")
}
}
}
// Now we can create something like SchematicUserDefault<SomeSchema>()
While SomeSchema conforming Codable, an auto-generated private enum CodingKeys will be provided. In my example, I used this CodingKeys to help generate a string identifier for each KeyPath so it can be used to access UserDefaults .
Now we can access our UserDefaults easily:
let userDefault = SchematicUserDefault<SomeSchema>()
userDefault.someString = "asd"
userDefault.someString = "waha"
userDefault.someInt = 123
print("(userDefault.someString)") // "Optional(waha)"
print("(userDefault.someInt)") // "Optional(asdasd)"
print("(userDefault.someFloat)") // "nil"
Advantages of this approach:
- Every SchematicUserDefault instance will have its own schema. You can set up different instances for different scope/grouping
- No need to worry about a typo in the string key or hard coding string keys for each access
- Syntax sugar, more persuasive to promote it to other developers
- Early compiler warning: Change of SomeSchema will immediately generate errors in KeyPath reference and CodingKeys to remind you what you need to change
- In theory, you can dump the whole SchematicUserDefault into a struct anytime
- The result data type is resolved when we try to access the members
Disadvantages of this approach:
- The code can be a bit long and complicated compared to propertyWrapper. Similar results with propertWrapper are as follows:
@propertyWrapper struct UserDefaultAccessor<T: Codable, Key: RawRepresentable> where Key.RawValue == String {
private var key: String
private let userDefaults: UserDefaults
var wrappedValue: T? {
get {
// User Default Access
}
set {
// User Default Right
}
}
init(_ key: Key, _ userDefaults: UserDefaults) {
self.key = key.rawValue
self.userDefaults = userDefaults
}
}
struct SomeSchema2 {
enum Key: String, CaseIterable {
case key1
case key2
}
private static let userDefault = UserDefaults.standard
@UserDefaultAccessor(Key.key1, SomeSchema2.userDefault) var key1: String?
@UserDefaultAccessor(Key.key2, SomeSchema2.userDefault) var key2: Int?
}
- It also needs a KeyPath -> String mapping.
The example only showcases how we can use the dynamicMemberLookup + KeyPath to do some fancy syntax sugar. We skipped most of the UserDefaults handling, which a UserDefaults helper should have. However, there are plenty of helpers on GitHub, so I won’t go into details here.
The main point is, Swift has lots of annotations to help us make our code much clean and easy to use. So, we can spend more time on how to change our approach to apply these features to our interfaces.
Thanks for reading.
Create UserDefaults Helper Using “dynamicMemberLookup” and KeyPath was originally published in Better Programming on Medium, where people are continuing the conversation by highlighting and responding to this story.