Search code examples
swiftnsuserdefaults

Associate each enum case with a type in Swift


I'm trying to apply the dependency inversion principle to the UserDefaults and also make it as strong-typed as possible.

Suppose that I have the following enum:

enum LocalStorageKeys: String {
    case user
    case account
}

Then, I need a LocalStorage protocol:

protocol LocalStorage {
    func save(key: LocalStorageKeys, value: Any) throws
    func get(key: LocalStorageKeys) -> Any?
}

I know that we could use a generic both in save and get methods, but how could I implement it in a way that when using localStorage.save(key: .user, value: user), it must be provided with the User, and only with the User type? Also, how could I do so the compiler can infer that localStorage.get(.user) is of type User?

In the end, I would like to protect myself from doing things like localStorage.save(key: .user, value: someOtherThing)

Thanks!


Solution

  • My solution based on how Apple does its serialisation is to have different key enumerations per type you want to be able to save.

    First create a protocol that anything that can be saved (or got back) must adopt

    protocol Saveable
    {
        associatedtype Key: CodingKey
    }
    

    Any type that you want to be able to save must have a Key nested type that conforms to CodingKey. The latter is only so that I have a consistent way to extract a key as a string from an instance of Key.

    Change your protocol as follows

    protocol LocalStorage
    {
        func save<T: Saveable>(value: T, for key: T.Key)
        func get<T: Saveable>(type: T.Type, key: T.Key) -> T?
    }
    

    If T.Key is an enum, whenever you save a value of type T you are automatically restricted to using the enum cases from T.Key. The reason I put value first is because it means that when you are typing a call to the function, by the time you get to for: the Xcode editor has already figured out what the allowable keys are and will give you a completion list to choose from.

    Here is how you might implement User

    
    struct User
    {
        var name: String
    }
    
    extension User: Saveable
    {
        enum Key: String, CodingKey
        {
            case user
            case otherUser
        }
    }
    

    In the above case, there are two allowed keys for storing a User.

    And here is a stub implementation of a LocalStorage

    
    struct StorageImplementation: LocalStorage
    {
        func save<T: Saveable>(value: T, for key: T.Key)
        {
            print(key.stringValue)
        }
    
        func get<T: Saveable>(type: T.Type, key: T.Key) -> T?
        {
            print(key.stringValue)
            return nil
        }
    }
    

    And this is what calling it looks like

    let user = User(name: "jeremy")
    StorageImplementation().save(value: user, for: .otherUser) // prints otherUser
    StorageImplementation().get(type: User.self, key: .user) // prints user