The power of key paths in Swift
Since Swift was originally designed with a strong focus on compile time safety and static typing, it mostly lacks the sort of dynamic features commonly seen in more runtime-focused languages like Objective-C, Ruby and JavaScript. For example, in Objective-C, we could dynamically access any property or method of an object — and even swap out its implementation — at runtime.
While this lack of dynamism is a major reason why Swift is so great, as it helps us write code that is more predictable and that has a higher chance of being correct, sometimes being able to work with our code in a more dynamic fashion would be really useful.
Thankfully, Swift keeps gaining more and more features that are more dynamic in nature, while still retaining its focus on type safe code. One such feature is key paths. This week, let’s take a look at how key paths work in Swift, and some of the cool and powerful things they can let us do.
The basics
Key paths essentially let us reference any instance property as a separate value. As such, they can be passed around, used in expressions, and enable a piece of code to get or set a property without actually knowing which exact property its working with.
Key paths come in three main variants:
KeyPath
: Provides read-only access to a property.WritableKeyPath
: Provides readwrite access to a mutable property with value semantics (so the instance in question also needs to be mutable for writes to be allowed).ReferenceWritableKeyPath
: Can only be used with reference types (such as instances of a class), and provides readwrite access to any mutable property.
There are some additional key path types as well, that are there to reduce internal code duplication and to help with type erasure, but we’ll focus on the main types in this article.
Let’s dive in and take a look at how key paths can be used, and what makes them interesting and potentially very powerful.
Functional shorthands
Let’s say that we’re building an app that lets the user read articles from around the web, and that we have an Article
model that we use to represent one such article, looking something like this:
struct Article {
let id: UUID
let source: URL
let title: String
let body: String
}
Whenever we’re working with an array of such models, it’s very common to want to extract a single piece of data from each one to form a new array — such as in the following two examples, in which we’re gathering all IDs and sources from an array of articles:
let articleIDs = articles.map { $0.id }
let articleSources = articles.map { $0.source }
While the above totally works, since we’re just interested in extracting a single value from each element, we don’t really need the full power of closures, so using key paths instead might be a great fit.
We’ll start by extending Sequence
to add an override of map
that takes a key path instead of a closure. Since we’re only interested in readonly access for this use case, we’ll use the standard KeyPath
type, and to actually perform the data extraction we’ll use subscripting with the given key path as an argument — like this:
extension Sequence {
func map<T>(_ keyPath: KeyPath<Element, T>) -> [T] {
return map { $0[keyPath: keyPath] }
}
}
Note: If you’re using Swift 5.2 or later, the above extension is no longer needed, since key paths can now be automatically converted into functions.
With the above in place, we’re now able to use a very nice and simple syntax to extract a single value from each element within any sequence, making it possible to transform our examples from before into this:
let articleIDs = articles.map(\.id)
let articleSources = articles.map(\.source)
That’s pretty cool, but where key paths really start to shine is when they’re used to form slightly more complex expressions, such as when sorting a sequence of values.
The standard library is able to automatically sort any sequence containing Sortable
elements, but for all other types we have to supply our own sorting closure. However, using key paths, we can easily add support for sorting any sequence based on a key path for a Comparable
value. Just like before, we’ll add an extension on Sequence
that converts a given key path into a 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]
}
}
}
Using the above, we’re now able to quickly and easily sort any sequence, simply by giving the key path we want to sort by. If the app we’re building deals with any form of sortable lists — for example a music app that contains playlists — this comes very much in handy, as we’re now free to sort our lists based on any comparable property (even nested ones):
playlist.songs.sorted(by: \.name)
playlist.songs.sorted(by: \.dateAdded)
playlist.songs.sorted(by: \.ratings.worldWide)
Doing something like the above may seem like simply adding syntactic sugar, but can both make some of our more complex code dealing with sequences easier to read, and can also help reduce code duplication, since we're now able to reuse the same sorting code for any property.
No instance required
While the right amount of syntactic sugar can be nice, the true power of key paths comes from the fact that they let us reference a property without having to associate it with any specific instance. Sticking with the music theme from before, let’s now say that we’re working on an app that displays lists of songs, and to configure a UITableViewCell
in the UI for such a list we use a configurator type which looks like this:
struct SongCellConfigurator {
func configure(_ cell: UITableViewCell, for song: Song) {
cell.textLabel?.text = song.name
cell.detailTextLabel?.text = song.artistName
cell.imageView?.image = song.albumArtwork
}
}
You can read more about using configurator types for setting up views in "Preventing views from being model aware in Swift".
Again, there’s nothing wrong with the above code, but chances are pretty high that we want to render other models in a similar fashion (many table view cells tend to render a title, subtitle and an image regardless of what model they represent), so let’s see if we can use the power of key paths to create a shared configurator implementation that can be used with any model.
Let’s create a generic type called CellConfigurator
, and since we want to render different data for different models, we’ll give it a set of key path-based properties — one for each piece of data that we’re looking to render:
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]
}
}
The beauty of the above approach is that we can now easily specialize our generic CellConfigurator
for each model using the same lightweight key path syntax from before — like this:
let songCellConfigurator = CellConfigurator<Song>(
titleKeyPath: \.name,
subtitleKeyPath: \.artistName,
imageKeyPath: \.albumArtwork
)
let playlistCellConfigurator = CellConfigurator<Playlist>(
titleKeyPath: \.title,
subtitleKeyPath: \.authorName,
imageKeyPath: \.artwork
)
Just like the standard library’s functional operations like map
and sorted
, we could’ve used closures to implement CellConfigurator
. However, with key paths, we’re able to achieve a very nice syntax — and we also don’t require any of our specializations to actually have to deal with model instances — making them both simpler and more declarative.
Converting to functions
So far we’ve only been reading values using key paths, but now let’s take a look at how we can use them to dynamically write values as well. A very common pattern seen in many different code bases is something like the following example — in which we’re loading a list of items to be rendered in a ListViewController
, and when the loading operation finishes we simply assign the loaded items to a property on the view controller:
class ListViewController {
private var items = [Item]() { didSet { render() } }
func loadItems() {
loader.load { [weak self] items in
self?.items = items
}
}
}
Let’s see if key paths again can help us make the above syntax a bit simpler, and if we also can get rid of that weak self dance we so often have to do (and with it - the risk of accidentally introducing retain cycles if we forget to capture a weak reference to self
).
Since all we’re really doing above is to take the value that was passed to our closure and assign it to a property on our view controller, wouldn’t it be cool if we were actually able to pass the setter for that property as a function? That way we could pass that function directly as the completion handler to our load
method and everything would just work.
To make that happen, let’s first define a function that lets us convert any writable key path into a closure that sets the property for that key path. This time we’ll use the ReferenceWritableKeyPath
type, since we want to restrict that functionality to reference types only (otherwise we’d only make changes to a local copy of a value). Given an object and a key path for that object, we’ll then automatically capture the object as a weak reference and assign the property matching the key path once our function gets called — 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
}
}
Using the above, we can now simplify our code from before, get rid of that weak self dance, and end up with a quite clean-looking syntax:
class ListViewController {
private var items = [Item]() { didSet { render() } }
func loadItems() {
loader.load(then: setter(for: self, keyPath: \.items))
}
}
Pretty cool! 😎 And perhaps even cooler, is when something like the above is combined with more advanced functional concepts such as function composition — since we’ll now be able to chain multiple setters and other functions together. We’ll take a closer look at that, and function composition in general, in upcoming articles.
Conclusion
At first, it can be somewhat difficult to see how and when to use features like Swift key paths, and it’s easy to dismiss them as simply being syntactic sugar. Being able to reference properties in a more dynamic way is a really powerful thing, and even though closures can often be used to achieve similar results, the lightweight syntax and declarative nature of key paths make them a really nice match for dealing with many kinds of data.
Being a relatively new feature (introduced in Swift 4), I'm sure that more ways to work with key paths will emerge in the community over time, so it's most likely a feature we’ll revisit again in future articles. Until then, I’d love to hear what you think about Swift’s key paths — do you already use them, or will you try them out? Let me know, along with your questions, comments or feedback — on Twitter @johnsundell.
Thanks for reading! 🚀