An Unheralded Perspective

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.

Tags: