The power of key Paths in Swift

Since Swift was originally designed to be compile-time secure and statically typed, it lacks the dynamic features that we often find in runtime languages such as object-c, Ruby, and JavaScript. For example, in object-c, it’s easy to dynamically retrieve any properties and methods of an Object- and even exchange their implementations at runtime.

While the lack of dynamism is a big part of what makes Swift so powerful – it helps us write more predictable code and more accurate code – sometimes it’s useful to be able to write dynamic code.

Thankfully, Swift continues to acquire more and more dynamic capabilities while keeping its focus on the type safety of code. One of these features is KeyPath. This week, let’s look at how KeyPath works in Swift, and what cool and useful things we can do.

Basic knowledge of

Key Paths essentially allow us to treat any property as an independent value. Thus, they can be passed, used in expressions, and enable a piece of code to get or set a property without knowing exactly which property they use.

There are three main variants of Key Paths:

  • KeyPath: Provides read-only access to attributes
  • WritableKeyPath: Provides readable and writable access to mutable attributes that have value semantics
  • ReferenceWritableKeyPath: can only be used for reference types (such as an instance of a class), and for any variable attributes provide readable can write access

There are a few additional keypath types that reduce internal duplication and can help with type erasing, but we’ll focus on the three main types above in this article.

Let’s dive into how key Paths are used and what makes them interesting and very powerful.

The function of shorthand

Let’s say we’re building an app that allows us to read articles from the Web, and we already have an Article model to express the Article, like this:

struct Article {
    let id: UUID
    let source: URL
    let title: String
    let body: String
}
Copy the code

Whenever we use this array of models, we usually need to extract individual data from each model to form a new array – like these two examples of getting all the columns of IDs and Sources from the article array:

let articleIDs = articles.map { $0.id }
let articleSources = articles.map { $0.source }
Copy the code

While the above implementation is perfectly fine, we just want to extract a single value from each element, and we don’t really need all the functionality of closures – so using keypath is probably a good fit. Let’s see how it works.

We will handle the key path by overriding the Map method in the Sequence protocol, rather than through closures. Since we are only interested in read-only access for this usage example, we will use the standard KeyPath type, and for actual data extraction, we will use the given key-value path as the subscript, as shown below:

extension Sequence {
	func map<T>(_ keyPath: KeyPath<Element, T>) -> [T] {
	  return map { $0[keyPath: keyPath] }
	}
}
Copy the code

With this in place, we can use friendly and simple syntax to extract a single value from any sequence element, making it possible to transform the preceding column into something like this:

let articleIDs = articles.map(\.id)
let articleSources = articles.map(\.source)
Copy the code

That’s pretty cool, but key-value paths really start to flash when they’re used to form more flexible expressions — like when ordering the values of a sequence.

The library can automatically sort any sequence containing sorted elements, but we must provide our own sort closure for non-sorted elements. However, using the critical path, we can easily add sorting support for any comparable element. As before, we add an extension to the sequence to translate the given critical path into the sorting expression closure:

extension Sequence {
    func sorted<T: Comparable>(by keyPath: KeyPath<Element, T>) -> [Element] {
        return sorted { a, b in
            return a[keyPath: keyPath] < b[keyPath: keyPath]
        }
    }
}
Copy the code

Using the above method, we can sort any sequence very quickly by simply providing a critical path that we expect to be sorted. This would be useful if we were building an app to handle any sortable list – for example, a music app that contains playlists – and we can now sort lists based on comparable attributes (even nested attributes) at will.

playlist.songs.sorted(by: \.name)
playlist.songs.sorted(by: \.dateAdded)
playlist.songs.sorted(by: \.ratings.worldWide)
Copy the code

Doing the above seems a bit simple, just like adding a grammatical sugar. But you can either write more flexible code to handle sequences, making them easier to read, or you can reduce code duplication. So we can now reuse the same sort code for any property.

You don’t need instances

While a moderate amount of syntactic sugar is great, the real power of the critical path comes from the fact that it allows us to refer to attributes without having to associate them with arbitrary instances. Continuing with the previous music theme, suppose we are developing an App that displays a list of songs – and configuring a UITableViewCell for this list in the UI. We use the following configuration types:

struct SongCellConfigurator { func configure(_ cell: UITableViewCell, for song: Song) { cell.textLabel? .text = song.name cell.detailTextLabel?.text = song.artistName cell.imageView?.image = song.albumArtwork } }Copy the code

Again, the above code is fine, but we expect a very high probability of rendering other models this way (a lot of tableView cells try to render titles, subtitles, and images regardless of what model they represent) – so let’s see, Can we use the power of the critical path to create a shared configuration implementation that can be used by any model?

Let’s create a generic type called CellConfigurator, and since we want to render different data with different models, we’ll give it a set of properties based on the critical path — let’s render one of them first:

struct CellConfigurator<Model> { let titleKeyPath: KeyPath<Model, String> let subtitleKeyPath: KeyPath<Model, String> let imageKeyPath: KeyPath<Model, UIImage? > func configure(_ cell: UITableViewCell, for model: Model) { cell.textLabel? .text = model[keyPath: titleKeyPath] cell.detailTextLabel?.text = model[keyPath: subtitleKeyPath] cell.imageView?.image = model[keyPath: imageKeyPath] } }Copy the code

The elegance of the above implementation is that we can now customize our CellConfigurator for each model, using the same lightweight critical path syntax, as shown below:

let songCellConfigurator = CellConfigurator<Song>(
    titleKeyPath: \.name,
    subtitleKeyPath: \.artistName,
    imageKeyPath: \.albumArtwork
)

let playlistCellConfigurator = CellConfigurator<Playlist>(
    titleKeyPath: \.title,
    subtitleKeyPath: \.authorName,
    imageKeyPath: \.artwork
)
Copy the code

You might have used a closure to implement a CellConfigurator, just like the map and sorted functions in the standard library do. However, with the critical path, we are able to implement them using a very good syntax – and we don’t need any custom operations that have to be handled through model instances – making them much simpler and more convincing.

Convert to a function

So far, we’ve only used critical paths to read values – now let’s see how we can use them to dynamically write values. In many different types of code, we often see examples like this — we load a list of items, render them in the ListViewController, and then when the load is complete, we simply assign the loaded items to properties in the view controller.

class ListViewController { private var items = [Item]() { didSet { render() } } func loadItems() { loader.load { [weak self] items in self? .items = items } } }Copy the code

Let’s see if critical path assignment can make the above syntax a little simpler and remove the weak self syntax that we often use (circular references are generated if we forget to prefix the weak keyword to self).

Since all we’re doing up here is taking the value passed to our closure, and assigning it to the property in our view controller – wouldn’t it be cool if we could actually pass the setter for the property as a function? This way we can pass the function directly to our loading method as a completion closure, and everything will do just fine.

To achieve this goal, we first define a function that converts any writable to a closure, and then set property values for the critical path. For this, we will use the ReferenceWritableKeyPath type because we only want to restrict it to reference types (otherwise, we will only change the value of the local property). Given an object and a critical path for that object, we will automatically treat the captured object as a weak reference type, and once our function is called, we will assign a value to the property that matches the critical path. Like this:

func setter<Object: AnyObject, Value>( for object: Object, keyPath: ReferenceWritableKeyPath<Object, Value> ) -> (Value) -> Void { return { [weak object] value in object? [keyPath: keyPath] = value } }Copy the code

Using the above code, we can simplify the previous code by removing the weak reference to self and ending it with a very succinct looking syntax:

class ListViewController {
    private var items = [Item]() { didSet { render() } }

    func loadItems() {
        loader.load(then: setter(for: self, keyPath: \.items))
    }
}
Copy the code

Very cool to have! Perhaps it will get even cooler when the above code is combined with more advanced ideas of functional programming, such as combinative functions – so we can now link multiple setters to other functions. In the next article, we’ll introduce functional programming and composing functions.

conclusion

First, it seems a little difficult how and when to use features like swift critical path, and it’s easy to treat them as simple syntactic sugar. Being able to refer to attributes in a more dynamic way is a very powerful thing, and even though closures can often do a lot of the same things, the lightweight syntax and critical path declarations make them good matches for handling a very wide variety of data.

Thanks for reading!