Type enforced UserDefaults

Type enforced UserDefaults

On iOS the UserDefaults is a persistent dictionary stored and managed by iOS that survives the application runtime. It can be used to store and retrieve key-value pairs of different data types in-between user sessions of the app.

In this article, we will cover how to define a new class and type-specific enum to ensure that each key can only read/written by a specific data type.

Problem definition

Given a key for UserDefaults, you can store and retrieve any type. For example:

let userDefaults = UserDefaults.standard
userDefaults.set(35, forKey: "MyValue")
userDefaults.set("I love Swift", forKey: "MyValue")

In the example above, we initially set an integer type value for the key “MyValue” but then we overwrite that value with a string. If the value is read back expecting an Integer we might not get what we expected:

let integerValue = userDefaults.integer(forKey: "MyValue")
print("MyValue:", integerValue)
// MyValue: 0

UserDefaults will silently store different types for the same key. It will also silently return to you automatically converted values between the last stored type for a given key and the type you are trying to retrieve.

While this flexibility may be useful, developers have to manually ensure they are using the correct data types associated to each key. Below is a method by which we can guarantee at compile time that any given key is only accessed using the expected type.


Enforcing Type in UserDefaults

Let’s start by creating a new class which will supervise read/write operations to UserDefaults for two types: Integer and String.

class TypeSafeUserDefaults {

    // Integer
    func integer(forKey key: String) -> Int {
        UserDefaults.standard.integer(forKey: key)
    }

    func set(_ integer: Int, forKey key: String) {
        UserDefaults.standard.set(integer, forKey: key)
    }

    // String
    func string(forKey key: String) -> String {
        UserDefaults.standard.string(forKey: key)
    }

    func set(_ string: String, forKey key: String) {
        UserDefaults.standard.set(string, forKey: key)
    }
}

So far, nothing would prevent us from encountering the same issue we did with UserDefaults. We could very easily set either an Integer or a String for any given key. Let’s solve this:

class TypeSafeUserDefaults {

    enum IntegerKey: StringLiteralType, RawRepresentable {
        case myIntegerValue
    }

    enum StringKey: StringLiteralType, RawRepresentable {
        case myStringValue
    }

    // Integer
    func integer(forKey key: IntegerKey) -> Int {
        UserDefaults.standard.integer(forKey: key.rawValue)
    }

    func set(_ integer: Int, forKey key: IntegerKey) {
        UserDefaults.standard.set(integer, forKey: key.rawValue)
    }

    // String
    func string(forKey key: StringKey) -> String? {
        UserDefaults.standard.string(forKey: key.rawValue)
    }

    func set(_ string: String, forKey key: StringKey) {
        UserDefaults.standard.set(string, forKey: key.rawValue)
    }
}

Let's see if it works....

Success! Our TypeSafeUserDefaults now prevents us from storing a string in a key expected to be used to store integers. Better yet, we get immediate feedback directly in Xcode and our project will fail to compile if we attempt to use the wrong type.


Ensuring all keys are unique

While the TypeSafeUserDefaults prevents storing values of the wrong type, if the two enums define the same keys, we can still end up with the same issue. Consider:

enum IntegerKey: StringLiteralType, RawRepresentable {
  case myValue
}

enum StringKey: StringLiteralType, RawRepresentable {
  case myValue
}

Unfortunately I haven’t found a way to get compile time errors from this. Luckily, Xcode supports unit tests so we can confirm our keys are unique by making our enum CaseIterable and using some unit tests. Let’s generate some enum with duplicated keys:

enum IntegerKey: StringLiteralType, RawRepresentable, CaseIterable {
    case myIntegerValue
    case myOtherValue
}

enum StringKey: StringLiteralType, RawRepresentable, CaseIterable {
    case myStringValue
    case myOtherValue
}

Now let’s implement a unit test to confirm all keys are unique:

import XCTest
@testable import TypeSafeUserDefaultsDemo

class TypeSafeUserDefaultsEnumTests: XCTestCase {

    func testKeysAreUnique() {
        var keysAlreadyEncountered: Set<String> = Set()

        TypeSafeUserDefaults.IntegerKey.allCases.forEach {
            keysAlreadyEncountered.insert($0.rawValue)
        }

        TypeSafeUserDefaults.StringKey.allCases.forEach {
            XCTAssertFalse(keysAlreadyEncountered.contains($0.rawValue), "StringKey \($0) conflicts with an already defined key")
            keysAlreadyEncountered.insert($0.rawValue)
        }
    }
}

When the unit tests run, we will now get this failure:

XCTAssertFalse failed — StringKey myOtherValue conflicts with an already defined key


I hope this saves you hours of troubleshooting. Enjoy!