Daniel Tull: Blog

Type-safe User Defaults

Wednesday, 09 October 2019

It’s common when referring to strings from multiple places to create a centralized location where they are defined. For keys used for accessing UserDefaults values, that may be a static property in an enum, which is used to act as a namespace for user default keys.

This allows you to use this property rather than a string literal, so that the compiler can help ensure the wrong value is not used, by telling you you have mis-spelled the name or some such.

enum UserDefaultKey {
  static let launchCount = "launchCount"
}

let defaults = UserDefaults.standard
let count = defaults.integer(forKey: UserDefaultKey.launchCount)
defaults.set(count + 1, forKey: UserDefaultKey.launchCount)

An issue with this approach though, is that strings can be used interchangeably so it may be possible to use a value which is not actually a user defaults key.

defaults.set(count + 1, forKey: "not the launch count key")

Creating a Key type

In these situations, it’s often helpful to create a wrapper type to ensure that only values specifically made to be user default keys can be used to access user default values.

extension UserDefaults {

  public struct Key {
    fileprivate let name: String
    public init(_ name: String) {
      self.name = name
    }
  }

  public func value(for key: Key) -> Any? {
    return object(forKey: key.name)
  }

  public func set(_ value: Any?, for key: Key) {
    set(value, forKey: key.name)
  }

  public func removeValue(for key: Key) {
    removeObject(forKey: key.name)
  }
}

We can then define keys as a static property inside an extension of the Key type.

This helps keep keys separate, so they can be defined within the same module where they are used and less likely to leak into other domains where they make no sense.

extension UserDefaults.Key {
  static let launchCount = UserDefaults.Key("launchCount")
}

Another reason for defining the key as a static property like this is that because our custom UserDefaults methods reference the Key type, Swift can infer the Key type and we can use a shorter dot syntax for more readable code.

let defaults = UserDefaults.standard
let count = defaults.value(for: .launchCount) as? Int ?? 0
defaults.set(count + 1, for: .launchCount)

While the code now helps with defining keys, it does nothing to help the compiler understand what value type is expected for a given key. The following will be accepted by the compiler despite being a bug.

defaults.set("Ooops", for: .launchCount)

Providing type-safety

To prevent unknown misuse, we can use a generic parameter to specify a type for the value that the key expects to be and use that to retrieve and store values to UserDefaults.

extension UserDefaults {

  public struct Key<Value> {
    fileprivate let name: String
    public init(_ name: String) {
      self.name = name
    }
  }
}

It’s perhaps interesting to note that the generic parameter of Value isn’t actually used anywhere within the body of the Key type. This is what’s known as a Phantom type, which surely makes this approach even better!

Using our new Key type, we can provide a stronger-typed API for UserDefaults. We specify Value as a generic parameter on the function so that we can use that type information in multiple places in the function definition.

The first function retrieves values from UserDefaults and says we take a key which has a Value type which is the same as the type of the returned value.

The second will only take a value which is of the same type as the key’s Value type.

The final one is a convenience where the generic parameter needs to be defined to be valid, but which isn’t really utilised.

extension UserDefaults {

  public func value<Value>(for key: Key<Value>) -> Value? {
    return object(forKey: key.name) as? Value
  }

  public func set<Value>(_ value: Value, for key: Key<Value>) {
    set(value, forKey: key.name)
  }

  public func removeValue<Value>(for key: Key<Value>) {
    removeObject(forKey: key.name)
  }
}

As we did before, we can define keys as static properties of the Key type, but now we must define the type of Value in the where clause of the extension otherwise Swift won’t know what Value should be. This becomes the definition for the type of Value represented by this key.

extension UserDefaults.Key where Value == Int {
  static let launchCount = Self("launchCount")
}

let defaults = UserDefaults.standard
let count = defaults.value(for: .launchCount) ?? 0
defaults.set(count + 1, for: .launchCount)

Due to the generic Value of the key, the call to value(for:) knows that it will be returned a value of Int type.

The call to set the value for the key is type-checked by the compiler, so that you cannot pass a non-integer value for this key, so the following line will fail to compile.

defaults.set("Noooope", for: .launchCount)

error: member ‘launchCount’ in ‘UserDefaults.Key<_>’ produces result of type ‘UserDefaults.Key<Int>’, but context expects ‘UserDefaults.Key<_>’

Our error message here is unfortunately a little backwards. Because we have passed a String in for the new value, the compiler complains that the launchKey has the wrong type. However because we trust the type of launchKey, we are alerted that we are actually trying to set a value of an incorrect type.

Conclusion

Since its release, Swift has provided ways to make nicer, stronger-typed and safer APIs than were previously afforded to us in Objective-C. These make our codebases easier to read and allow the compiler to help us make the correct choices, reducing the potential for incorrect outcomes before the code is even run.

For me, the biggest benefit of Swift is its type-safety showing us incorrect states at compile time, rather than having to discover bugs at more costly stages of development such as unit tests, quality assurance or the worst outcome, a user discovering it.