A collection of key paths, same root type but different values
I love Swift KeyPaths as they allow us to work in a type-safe environment. But I ran into a problem the other day when using them for a feature I was implementing.
I wanted the ability to create an array of KeyPaths where the Root was the same type but the Value types were different. The first thing I tried was this:
class Dog {
@objc let name: String = ""
@objc let age: Int = 0
}
let keyPaths = [\Dog.name, \Dog.age]
Xcode reports that keyPaths has the type PartialKeyPath<Dog>. This actually makes sense because name is a String while age is an Int. So, Swift uses a PartialKeyPath, which type-erases the Values for you.
This was great but didn't work for my particular problem. I wanted to use KeyPaths to represent property names in a type-safe manner. Given a KeyPath, print out the property name of the value, like so:
func printPropertyName<Root, Value>(keyPath: KeyPath<Root, Value>) {
let propertyName = NSExpression(forKeyPath: keyPath).keyPath
print(propertyName)
}
printPropertyName(keyPath: \Dog.name) // prints name
printPropertyName(keyPath: \Dog.age) // prints age
NSExpression is a great Apple API that gives us the ability to retrieve the property name of a KeyPath, if the property is annotated with @objc.
Next, I wanted the ability to pass in a collection of KeyPaths like so:
printPropertyNames(keyPaths: [\Dog.name, \Dog.age])
As we saw earlier, the type of the array would be PartialKeyPath<Dog>. Unfortunately, since the KeyPath's Value is type-erased, we loose the ability to retrieve the property names and NSExpression() no longer works for us.
func printPropertyNames<Root>(keyPaths: [PartialKeyPath<Root>]) {
keyPaths.forEach { keyPath in
let valueName = NSExpression(forKeyPath: keyPath).keyPath
print(valueName)
}
}
printPropertyNames(keyPaths: [\Dog.name, \Dog.age])
The compiler tells us we're crazy by throwing an error Cannot invoke initializer for type 'NSExpression' with an argument list of type '(forKeyPath: (PartialKeyPath<Root>))' for the code shown above.
So, how do we get around this problem? Well, one approach is to wrap our KeyPath access in a closure.
Let's start by defining a function that makes this wrapping closure for us. We'll refer to the closure as a PropertyRef:
typealias PropertyName = String
func makePropertyRef<Root, Value>(keyPath: KeyPath<Root, Value>) -> (Root.Type) -> PropertyName {
return { rootType in
let propertyName = NSExpression(forKeyPath: keyPath).keyPath
return propertyName
}
}
Now, given a KeyPath, we can make a PropertyRef. All that remains is to rewrite our print property name function to work with PropertyRefs instead of KeyPaths:
func printPropertyNames<Root>(propertyRefs: (Root.Type) -> PropertyName...) {
let propertyNames = propertyRefs.map { $0(Root.self) }
print(propertyNames)
}
printPropertyNames(propertyRefs: makePropertyRef(keyPath: \Dog.name), makePropertyRef(keyPath: \Dog.age))
This is great because we retain the type-safety that KeyPaths give us and it stops us from mixing Root types, which is exactly what we want.
printPropertyNames(propertyRefs: makePropertyRef(keyPath: \Dog.breed)) // compiler error
printPropertyNames(propertyRefs: makePropertyRef(keyPath: \Dog.name), makePropertyRef(keyPath: \Cat.age)) // compiler error
So, there you have it. We now have the ability to produce a collection of KeyPaths where our Root is the same but the values are different while retaining our ability to access the property names of the Values.