on
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.