KeyPath: one hidden gem in Swift

👍
· Dec 26, 2023 · 7 mins read
KeyPath: one hidden gem in Swift

KeyPath in Swift provides a different way to access object properties apart from dot syntax, which adds flexibilities in coding practice. The KeyPath can also behave as a function since Swift 5.2 and this usage can been seen in many language APIs. For example:

struct User {  
    let name: String  
}  
  
var users: [User] = []  
  
// Using closure  
users.map { $0.name }  
  
// Using keypath  
users.map(\.name)

In this article, we are going to explore how KeyPath can help us.

Work with @dynamicMemberLookup

This should be one of the most widely known use cases that KeyPath shines. It is a feature added to Swift 5.1 and the original proposal is here. Without KeyPath, we could have use @dynamicMemberLookup like below:

@dynamicMemberLookup  
struct User {  
    struct Address {  
        let city: String  
        let postcode: String  
    }  
  
    let name: String  
    let address: Address  
  
    subscript(dynamicMember member: String) -> String {  
        switch member {  
        case "city": return address.city  
        case "postcode": return address.postcode  
        default: return ""  
        }  
    }  
}  
  
let user = User(name: "Tim", address: .init(city: "Sydney", postcode: "2000"))  
  
print(user.city)  
print(user.postcode)

With the above setup, @dynamicMemberLookup enables us to access city and postcode at user level even though they are properties of address property of the User.

However, this approach is limited: we have to handle member parameter as a String, which is error prone. Also, one subscript method can only return one type etc. With KeyPath, it becomes much better. Let’s see the example below:

@dynamicMemberLookup  
struct User {  
    struct Address {  
        let city: String  
        let postcode: String  
        let distance: Measurement<UnitLength>  
    }  
  
    let name: String  
    let address: Address  
  
    // Using KeyPath  
    subscript<Value>(dynamicMember keypath: KeyPath<Address, Value>) -> Value {  
        address[keyPath: keypath]  
    }  
}  
  
let user = User(name: "Tim", address: .init(city: "Sydney",   
                                            postcode: "2000",  
                                            distance: .init(value: 500, unit: .kilometers)))  
print(user.city)  
print(user.postcode)  
print(user.distance)

Now, with fewer lines of code, we have a more powerful dynamicMemberLookup method. As the subscript method is a generic method, it can handle different types(String and Measurement<UnitLength> in this case).

Process properties in batches

KeyPath instances are values and we can store them in collection types like array, set and dictionary etc. By doing so, we group KeyPaths together and can process the values they refer to in batches.

In the following example, we store the default values in a dictionary where KeyPath is the key and reset the device to its default state using one loop in resetToDefault method. This is convenient because we do not need to write properties line by line in this method. Even we add new defaults in the dictionary, we don’t need to update this method at all.

class Device {  
    static let defaults: [ReferenceWritableKeyPath<Device, Int>: Int] = [\.volume: 3,  
                                                                        \.bassLevel: 2,  
                                                                        \.trebleLevel: 2,  
                                                                        \.noiseCancellingLevel: 5]  
    private var volume: Int  
    private var bassLevel: Int  
    private var trebleLevel: Int  
    private var noiseCancellingLevel: Int  
  
    init(volume: Int, noiseCancellingLevel: Int, bassLevel: Int, trebleLevel: Int) {  
        self.volume = volume  
        self.noiseCancellingLevel = noiseCancellingLevel  
        self.bassLevel = bassLevel  
        self.trebleLevel = trebleLevel  
    }  
  
    // Reset all property values to defaults  
    func resetToDefault() {  
        Device.defaults.forEach { key, value in  
            self[keyPath: key] = value  
        }  
    }  
}

Here, we are using ReferenceWritableKeyPath so that we can write values using self to this device class.

Work with Enum to manage the flow

Another use case that I found KeyPath is helpful is that we can use it along with Enum to do the flow control. Let’s imagine we need to take actions based on user’s input. For this we can have the code written like below:

var userInput: String?  
  
enum Action: String {  
    case up, down, left, right  
}  
  
class ActionManager {  
    var upAction: () -> Void {{ }}  
    var downAction: () -> Void {{ }}  
    var leftAction: () -> Void {{ }}  
    var rightAction: () -> Void {{ }}  
    let actionHandlers: [Action: KeyPath<ActionManager, () -> Void>] = [.up: \.upAction,  
                                                                        .down: \.downAction,  
                                                                        .left: \.leftAction,  
                                                                        .right: \.rightAction]  
}  
  
let actionManager = ActionManager()  
  
if let userInput,  
    let action = Action(rawValue: userInput),  
    let keypath = actionManager.actionHandlers[action]  {  
    actionManager[keyPath: keypath]()  
}

With the help of the KeyPath, we define the mapping logic in a dictionary and use it in the main control flow. You might argue this wouldn’t help much. It is true to some extent. However, the usage of KeyPath here decouples the action mapping logic from the main control flow. And the obvious benefit gained here is that this separated mapping is easier to maintain.

For instance, if we need to add new action cases, we do not need to touch the main flow. What we need to to is to add the new enum cases, define the action handler for them and hook them in the actionHandlers dictionary. And the best part is that all these can be done within a small block of code, which is easier to handle and less probability of introducing bugs.

Identity KeyPath

When we are in a KeyPath context, we can use identify KeyPath .self to access to the object itself. Even though it’s identical to object itself, and in most cases we can use the variable itself for the same purpose, it does provide some flexibility sometimes.

Imagine we have log function that uses KeyPath to print out values like below. We can use .self the whole value.

struct Customer {  
    let name: String  
    let age: Int  
  
    func logMe<Value>(keyPath: KeyPath<Customer, Value>) {  
        print("\(keyPath): \(self[keyPath: keyPath])")  
    }  
}  
  
let customer = Customer(name: "Tim", age: 40)  
customer.logMe(keyPath: \.name) // Will print: \Customer.name: Tim  
customer.logMe(keyPath: \.age) // Will print: \Customer.age: 40  
customer.logMe(keyPath: \.self) // Will print: \Customer.self: Customer(name: "Tim", age: 40)

Sorting arrays using KeyPath

As we know, if we need to sort an array, we can use the sort or sorted methods in Swift like below:

struct User {  
    let firstName: String  
    let lastName: String  
}  
  
var users: [User] = []  
  
let sorted = users.sorted { $0.firstName > $1.firstName }  
// Or  
users.sort { $0.firstName > $1.firstName }  
  
// Or implement Comparable on User so that can call `sorted` or `sort` without paramaters   
extension User: Comparable {  
    static func < (lhs: User, rhs: User) -> Bool {  
        lhs.firstName > rhs.firstName  
    }  
}  
let anotherSorted = users.sorted()  
users.sort()

The above approach works. But what if we want to sort users based on lastName ? If we add the following extension to Array using KeyPath, we have the flexibility to choose sorting criteria with ease.

extension Array {  
    func sorted<Value: Comparable>(on keypath: KeyPath<Element, Value>,   
                                   by areInIncreasingOrder: (Value, Value) throws -> Bool) rethrows -> [Element] {  
        try sorted {  
            try areInIncreasingOrder($0[keyPath: keypath], $1[keyPath: keypath])  
        }  
    }  
  
    mutating func sort<Value: Comparable>(on keypath: KeyPath<Element, Value>,  
                                 by areInIncreasingOrder: (Value, Value) throws -> Bool) rethrows {  
        try sort {  
            try areInIncreasingOrder($0[keyPath: keypath], $1[keyPath: keypath])  
        }  
    }  
}  
  
// Now we can do  
let sortedByFirstName = users.sorted(on: \.firstName, by: >)  
let sortedByLastName = users.sorted(on: \.lastName, by: >)  
users.sort(on: \.firstName, by: >)  
users.sort(on: \.lastName, by: >)  
  
// Or sort in the opposite order  
let sortedByFirstName = users.sorted(on: \.firstName, by: <)  
let sortedByLastName = users.sorted(on: \.lastName, by: <)  
users.sort(on: \.firstName, by: <)  
users.sort(on: \.lastName, by: <)

As we can see, the usage of KeyPath in this extension give us the freedom in sorting arrays. This syntax is more concise if we need to refer to a deeper level of the value. Also, because we only specify the property once using KeyPath, it reduces the possibility of writing buggy code like $0.firstName > $1.lastName , where we wrongly compare firstName with lastName .

Conclusion

KeyPath is a standalone value and, compared with dot syntax that we have to use it along with variables, this decoupling gives us freedom to access to property values. Using it right can help us write expressive and concise code.

Comments

Sharing is caring!