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.namevar 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.
If you’re not familiar with Combine, checkout my article here.
— 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 as`map` 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.