Search code examples
swiftgenericskeypathsswift-keypath

Convert AnyKeyPath to ReferenceWritableKeyPath with Optionals


Context

Consider this class and the KeyPath instances:

class Foo {
   var name: String? = ""
}

var foo = Foo()
let keyPath: ReferenceWritableKeyPath<Foo, String?> = \.name

let keyPaths: [AnyKeyPath] = [keyPath]

Question

We now have a collection of type-erased AnyKeyPath that are really ReferenceWritableKeyPath under the hood. (The rootType and valueType of the AnyKeyPath are set.)

I want to convert the AnyKeyPath back to a ReferenceWritableKeyPath and use it to set foo.name = nil. But the ergonomics of that are downright laughable. Here's what I've got:

func tryWrite<Base>(_ newValue: Any?, to: inout Base, through: AnyKeyPath, withKnownKeyPathValueType kpValueType: Any) 
{
    return _tryWrite(newValue, to: &to, through: through, withKnownKeyPathValueType: kpValueType)
}

func _tryWrite<Base, Value>(_ newValue: Any?, to: inout Base, through: AnyKeyPath, withKnownKeyPathValueType: Value)
{
    guard let kp = through as? ReferenceWritableKeyPath<Base, Value> else {
        print("failed to cast keypath")
        return
    }
    to[keyPath: kp] = (newValue as! Value)
}


tryWrite(nil, to: &foo, through: keyPaths.first!, withKnownKeyPathValueType: Optional(""))

This works, but there HAS to be a better way. What is that better way?

Background

This is a simplified example. In reality, I'm implementing "delete rules" in a data framework similar to SwiftData. You specify them the same way:

@Relationship(deleteRule: .nullify, inverse: \Bar.blah) var children: [Bar] = []

During a delete operation, I coalesce all the objects that need references nullified, which is how I end up with a collection of [AnyKeyPath] that I need to turn back into ReferenceWritableKeyPath.

Hence the need to make KeyPath work rather than some other alternative, such as capturing closures.


Solution

  • Your own answer here works with optionals too.

    It's just that you cannot directly pass nil to the with: parameter. You need to construct a nil from the value type of the key path. Just like how you opened the existential Any with an extra type parameter in _tryWrite, you can open an existential metatype in the same way.

    // this is helper function we use to open the existential
    func typedNil<T: ExpressibleByNilLiteral>(of type: T.Type) -> T {
        return nil
    }
    
    // this is same as your answer in the linked question
    func _tryWrite<Base, Value>(to: inout Base, through: AnyKeyPath, with: Value) {
        guard let kp = through as? ReferenceWritableKeyPath<Base, Value> else {
            print("failed to cast keypath")
            return
        }
        to[keyPath: kp] = with
    }
    
    func tryWriteNil<Base>(to: inout Base, through: AnyKeyPath) {
        guard let valueType = type(of: through).valueType as? any ExpressibleByNilLiteral.Type else {
            print("Key path value type is not optional!")
            return
        }
        return _tryWrite(to: &to, through: through, with: typedNil(of: valueType))
    }