1

So I had this idea of creating an expiry for UserDefaults. This is the approach I was starting to take but I'm stuck.

struct TimedObject<T: Codable>: Codable {
    let object: T
    let expireDate: Date
}

and then:

extension UserDefaults {
    
    func set<T: Codable>(_ value: T, forKey key: String, expireDate: Date) {
        let timedObject = TimedObject<T>(object: value, expireDate: expireDate)
        let encoder = JSONEncoder()
        if let encoded = try? encoder.encode(timedObject) {
            UserDefaults.standard.set(encoded, forKey: key)
    }

    override open class func value(forKey key: String) -> Any? {
        guard let value = self.value(forKey: key) else {
            return nil
        }
        if TimedObject<???>.self == type(of: value) { // This is where I'm stuck
            ...
        }
    }

So if I would name the type and not use generics I would easily solve this. But naturally I prefer to use generics. Can this be done?

gerbil
  • 859
  • 7
  • 26

3 Answers3

1

I know OP is using a struct to wrap the stored value but I would still like to offer a different protocol based solution where any type that should be stored with an expiration date needs to conform to this protocol.

Here is the protocol for I am using

protocol TimedObject: Codable {
    associatedtype Value: Codable

    var value: Value { get }
    var expirationDate: Date { get }
}

and the functions to store and retrieve from UserDefaults

extension UserDefaults {
    func set<Timed: TimedObject>(_ value: Timed, forKey key: String) {
        if let encoded = try? JSONEncoder().encode(value) {
            self.set(encoded, forKey: key)
        }
    }

    func value<Timed: TimedObject>(_ type: Timed.Type, forKey key: String) -> Timed.Value? {
        guard let data = self.value(forKey: key) as? Data, let object = try? JSONDecoder().decode(Timed.self, from: data) else {
            return nil
        }
        return object.expirationDate > .now ? object.value : nil
    }
}

Finally an example

struct MyStruct: Codable {
    let id: Int
    let name: String
}

extension MyStruct: TimedObject {
    typealias Value = Self

    var value: MyStruct { self }

    var expirationDate: Date {
        .now.addingTimeInterval(24 * 60 * 60)
    }
}

let my = MyStruct(id: 12, name: "abc")

UserDefaults.standard.set(my, forKey: "my")

let my2 = UserDefaults.standard.value(MyStruct.self, forKey: "my")
Joakim Danielson
  • 43,251
  • 5
  • 22
  • 52
0

If you make the value() method generic then you can reverse the process done in the set() method: retrieve the data and decode it as a TimedObject<T>.

However, I would choose a different name to avoid possible ambiguities with the exisiting value(forKey:) method. Also I see no reason why this should be a class method.

Note also that your generic set() method should call the non-generic version on the same instance.

extension UserDefaults {
    
    func set<T: Codable>(_ value: T, forKey key: String, expireDate: Date) {
        let timedObject = TimedObject(object: value, expireDate: expireDate)
        let encoder = JSONEncoder()
        if let encoded = try? encoder.encode(timedObject) {
            set(encoded, forKey: key)
        }
    }
    
    func expiringValue<T: Codable>(forKey key: String) -> T? {
        guard let data = self.data(forKey: key) else {
            return nil
        }
        let decoder = JSONDecoder()
        guard let decoded = try? decoder.decode(TimedObject<T>.self, from: data) else {
            return nil
        }
        // check expire date ...
        return decoded.object
    }
}

Example usage:

let val1 = UserDefaults.standard.expiringValue(forKey: "foo") as String?
let val2: String? = UserDefaults.standard.expiringValue(forKey: "bar")

In both cases, expiringValue(forKey:) is called with the inferred type.

Or in combination with optional binding:

if let val: String = UserDefaults.standard.expiringValue(forKey: "test") {
    print(val)
}

Another option is to pass the desired type as an additional argument:

    func value<T: Codable>(forKey key: String, as: T.Type) -> T? {
        guard let data = self.data(forKey: key) else {
            return nil
        }
        let decoder = JSONDecoder()
        guard let decoded = try? decoder.decode(TimedObject<T>.self, from: data) else {
            return nil
        }
        // check expire date ...
        return decoded.object
    }

which is then used as

let val = UserDefaults.standard.value(forKey: "foo", as: String.self)
Martin R
  • 529,903
  • 94
  • 1,240
  • 1,382
  • Thank you. I wish it was possible to preserve the override. – gerbil Sep 13 '22 at 09:10
  • @gerbil: Why? `UserDefaults` has no generic `value(forKey:)` class or instance method, so there is nothing to override. – Martin R Sep 13 '22 at 09:11
  • Well, just to keep things simple. value(for:..) should ideally just work. Return nil if the value expired. I don't want the API to require the user to know this value was set with expiration. – gerbil Sep 13 '22 at 09:13
  • @gerbil: Overriding existing methods in an extensions is a *bad idea.* It is possible only for backward compatibility with Objective-C. Compare https://stackoverflow.com/q/38213286/1187415. – Martin R Sep 13 '22 at 09:15
  • @gerbil: There is also an error in your setter method. There can be other than the “standard” user defaults. That is important when preferences are shared between apps or between an app an an extension. – Martin R Sep 13 '22 at 09:55
0

Since you're returning Any?, it is best to create another struct to point to TimedObject as you don't need the object property when returning Any?:

struct Expiry: Codable {
    var expireDate: Date
}

struct TimedObject<T: Codable>: Timable {
    let object: T
    var expireDate: Date
}

override open class func value(forKey key: String) -> Any? {
    guard let value = self.value(forKey: key) as? Data else {
        return nil
    }
    if let timed = try? JSONDecoder().decode(Expiry.self, from: value) {
    //do anything with timed
    }
    //make sure to return value
}

And here's a method to access TimedObject:

func timedObject<T: Codable>(forKey key: String) -> TimedObject<T>? {
    guard let value = self.data(forKey: key) as? Data, let timed = try? JSONDecoder().decode(TimedObject<T>.self, from: value) else {
        return nil
    }
    return value
}
Timmy
  • 4,098
  • 2
  • 14
  • 34