Swift - KeyPaths

Introduced with Swift 4, the KeyPath type is a powerful feature that enables us to store a type’s property and defer its evaluation.

public class KeyPath<Root, Value>: PartialKeyPath<Root> {
    @usableFromInline
    internal final override class var _rootAndValueType: (
        root: Any.Type,
        value: Any.Type
    ) {
        return (Root.self, Value.self)
    }
    {…}
}

The KeyPath class takes two generic types namely Root and Value which expects a specific root type and a specific resulting value type. A KeyPath is declared with the following syntax \Root.value which will hold a reference to a property itself rather than to that property’s value.

struct Guitar {
    let model: String
    let year: Int
}
let gibson = Guitar(model: "Gibson", year: 1990)
let path = \Guitar.model /// KeyPath<Guitar, String>

The path property is referencing the model property from the Guitar class which can be later accessed using the following syntax [keyPath: path]

print(gibson[keyPath: path])
// prints “Gibson”

Because both our model property and our gibson instances are declared as constants, the compiler will infer the type as KeyPath<Guitar, String> which is read-only, preventing us from writing to that property.

Switching to a var will change the inferred type to WritableKeyPath<Guitar, String> allowing us to mutate the model.

struct Guitar {
    var model: String
    let year: Int
}
var gibson = Guitar(model: "Gibson", year: 1990)
let path = \Guitar.model /// WritableKeyPath<Guitar, String>
gibson[keyPath: path] = "Martin"
print(gibson[keyPath: path])
// prints "Martin"

If the Guitar object was a class, the inferred type would be ReferenceWritableKeyPath<Guitar, String> since classes are reference types.

— Combined KeyPaths

The nice thing about keypaths is that we can combine them to form a full path.

struct Guitar {
    var owner: Owner
    var model: String
    let year: Int
}
struct Owner {
    var name: String
    let birthday: String
}

let owner = Owner(name: "John Doe", birthday: "01/01/2000")
var gibson = Guitar(owner: owner, model: "Gibson", year: 1990)
let ownerPath = \Guitar.owner
let ownerNamePath = \Owner.name
var guitarOwnerPath = ownerPath.appending(path: ownerNamePath)

gibson[keyPath: guitarOwnerPath] = "Jane Doe"
print(gibson[keyPath: guitarOwnerPath])
// prints "Jane Doe"

_— KeyPaths in functions__

Let’s now leverage the full power of KeyPaths using higher order functions such as map() and filter().

struct Guitar {
    var owner: Owner
    var model: String
    let year: Int
}
struct Owner {
    var name: String
    let birthday: String
}

var gibson = Guitar(owner: Owner(name: "John Doe", birthday: "01/01/1990"), model: "Gibson", year: 1990)
var martin = Guitar(owner: Owner(name: "Jane Doe", birthday: "01/01/1999"), model: "Martin", year: 1989)
var fender = Guitar(owner: Owner(name: "Jack Doe", birthday: "01/01/1998"), model: "Fender", year: 1987)
let guitars = [gibson, martin, fender]

Mapping over the guitars to retrieve different owners would traditionally result in the following implementation.

let owners = guitars.map { $0.owner }

Using keypaths as functions arguments gives us the power to write declarative code while keeping our implementation in a separate function. The with function takes a KeyPath parameter and returns a function which, itself, takes the Root object as a parameter and returns the type property’s value.

func with<Root, Value>(
    _ keyPath: KeyPath<Root, Value>) -> (Root) -> Value {
        return { root in
            root[keyPath: keyPath]
        }
}
let owners = guitars.map(with(\.owner))
let models = guitars.map(with(\.model))

Let’s continue the experimentation with the filter function.

func `where`<Root, Value: Comparable>(
    _ keyPath: KeyPath<Root, Value>,
    _ operation: @escaping (_ lhs: Value, _ rhs: Value) -> Bool,
    _ matches: Value) -> (Root
) -> Bool {
    return { root in
        operation(root[keyPath: keyPath], matches)
    }
}

The where function takes three arguments that is a KeyPath, an operator and the value to match against.

let filtered = guitars.filter(`where`(\.year, <, 1990))

Thanks to these helper functions, both map and filter operations can almost be read as plain English text enabling us to write more declarative code.

— KeyPath in Combine

Combine uses the KeyPath type in its assign function.

func assign<Root>(
    to keyPath: ReferenceWritableKeyPath<Root, [Headlines]>,
    on object: Root
) -> AnyCancellable

Making it a real-life example with the following excerpt from my SwiftUI app called Headlines.

final class HeadlinesViewModel: ObservableObject, ViewModel {
    var webService: Webservice
    private var cancellable: Set<AnyCancellable>
    @Published var headlines: [Headlines] = []
    
    {..}
    
    func fire() {
        let data = webService.fetch(
            preferences: preferences, 
            keyword: keyword.value).map { value -> [Headlines] in
                let headlines = value.map { 
                    (section, result) -> Headlines in
                        let isFavorite = self.preferences.categories
                            .first(where: { $0.name == section })?
                            .isFavorite
                        return Headlines(
                            name: section, 
                            isFavorite: isFavorite ?? false, 
                            articles: result.articles
                        )
                 }
                return headlines.sortedFavorite()
            }
            .receive(on: DispatchQueue.main)
            .assign(to: \HeadlinesViewModel.headlines, on: self)
            cancellable.insert(data)
    }    
}

When the data is fetched and processed from a web-service, the result is assigned to a specific property using a ReferenceWritableKeyPath type — e.g HeadlinesViewModel.headlines — and the top object this property belongs to — HeadlinesViewModel — is also specified.

— Conclusion

KeyPaths are a great tool to create nicely designed APIs while leveraging powerful Swift features such as generics.

Customized KeyPaths functions that can be passed in as arguments to higher order functions such asmap or filter enables us to write much more declarative code which results in better readability.

With Combine, Apple’s native solution to reactive programming, KeyPaths are used to their full extent.