With keypaths in Swift, you’re getting a reference to a property directly, as opposed to its value. You can pass these keypaths around in your code, and create all sorts of metaprogramming mayhem with it. Let’s find out more!
In this tutorial, we’ll discover:
WritableKeyPath
, Any
and genericsReady? Let’s go.
Keypaths in Swift are a way of storing a reference to a property, as opposed to referencing property’s value itself. It’s like working with the name of the property, and not its value.
Let’s take a look at an example:
struct Videogame {
var title:String
var published:String
var rating:Double
}
let cyberpunk = Videogame(title: "Cyberpunk 2077", published: "2020", rating: 5)
let titleKeyPath = \Videogame.title
print(cyberpunk[keyPath: titleKeyPath]) // Output: Cyberpunk 2077
In the above code, we’ve defined a struct Videogame
with a few properties. We’re also creating a Videogame
object and assign it to cyberpunk
, using the memberwise initializer.
Next, we’re creating a keypath and assign it to the titleKeyPath
constant. The key path itself is \Videogame.title
, which is a reference to the title
property of the Videogame
struct.
On the last line, we’re using the titleKeyPath
to read the value of title
on the cyberpunk
object. That’s what a keypath is!
In the previous example, we’ve used the keypath \Videogame.title
to read the property title
of a Videogame
object. Why would you use a keypath to read it, why not just use cyberpunk.title
directly? Let’s find out.
Keypaths are a form of metaprogramming. You dynamically read (or write) an object’s properties, using a reference to the property. The property name title
becomes a value. Metaprogramming, in this sense, is using the code itself as data. We can take the keypath \Videogame.title
and pass it around in our code.
Let’s take a step back, and make a comparison with dictionaries. Check this out:
let cyberpunk:[String: Any] = [
"title": "Cyberpunk 2077",
"published": "2020",
"rating": 5
]
let path = "title"
print(cyberpunk[path]!)
In the above code, we’ve created a similar data structure as the Videogame
struct. The "title"
key is assigned to path
, and we use it to read the title value from the cyberpunk
dictionary.
So far so good, but …
"title"
key doesn’t exist in the dictionary?"ttile"
?At best, you’ll find out when your app runs and crashes. At worst, you’ll introduce a bug into your code that takes some time to debug. With keypaths, you won’t make the same mistake, because they’re typed and checked at compile-time.
In the previous section, we’ve worked with the following keypath:
let titleKeyPath = \Videogame.title
print(cyberpunk[keyPath: titleKeyPath])
The type of titleKeyPath
is WritableKeyPath<Videogame, String>
. We can infer a few things from the type:
WritableKeyPath
, because the title
property we referenced is declared with var
, so it’s writable (or “mutable”). Would we have declared it with let
(immutable), the type would have been KeyPath<···>
.Videogame
in the type, as well as String
. These are clearly references to the Videogame
struct itself, as well as a reference to the type String
of the title
property.Because of these concrete types, Swift can check at compile time that the keypath you’re using in cyberpunk[keyPath: titleKeyPath]
is valid. Swift can check whether it exist, whether the type matches, and if the keypath is readable or writable. This makes your code less error-prone, safer, and more productive – that’s exactly what the Swift programming language excels at!
Keypaths in Swift have a few more types, which mostly revolve around type-erasure, like with Any. When you combine or allow multiple keypaths, for example in an array, you can use the PartialKeyPath
and AnyKeyPath
to construct types that fit multiple keypaths.
The real reason keypaths are so awesome, is because you can use them like values. You can pass references to properties around in your code, and do all sorts of things with them.
Check out the following code:
extension Array
{
func sorted<Value: Comparable>(
keyPath: KeyPath<Element, Value>,
by areInIncreasingOrder:(Value, Value) -> Bool) -> [Element] {
return sorted { areInIncreasingOrder(
$0[keyPath: keyPath], $1[keyPath: keyPath]) }
}
}
In the above code, we create an extension for the Array
type, adding in a new sorted(keyPath:by:)
function. This function works the same way as sorted(by:)
, so it’ll take a closure that determines the sorting. In addition to that, sorted(keyPath:by:)
takes a keypath that should be used for sorting.
Here’s an example:
let games = [
Videogame(title: "Cyberpunk 2077", published: "2020", rating: 999),
Videogame(title: "Fallout 4", published: "2015", rating: 4.5),
Videogame(title: "The Outer Worlds", published: "2019", rating: 4.4),
Videogame(title: "RAGE", published: "2011", rating: 4.5),
Videogame(title: "Far Cry New Dawn", published: "2019", rating: 4),
]
for game in games.sorted(keyPath: \Videogame.rating, by: >) {
print(game.title)
}
// Output: Cyberpunk 2077, Fallout 4, RAGE, The Outer Worlds, Far Cry New Dawn
Here’s what happens in the above code:
Videogame
objects are added to the games
arraygames.sorted(···)
, the games are sorted by the keypath \Videogame.rating
in descending orderNow that you’ve coded this function, you can use any kind of keypath to sort the games
array. You could sort by title, published date, rating, and so on. Instead of hard-coding those properties, you pass the keypath into the sorted(···)
function, to dynamically sort by property. That’s the power of metaprogramming with keypaths!
Credits for the sorted(keyPath:by:)
code go to Cal Stephens, who proposed to add keypath-based sorting to the Swift language itself. The code works by defining a generic function for arrays, which accepts a keypath in the form of KeyPath<Element, Value>
, where Element
is the object we’re sorting and Value
is the type of the sort property. Inside the function, the standard sorted(by:)
function is used to sort the array based on a predicate. This predicate is the areInIncreasingOrder
closure, which will return true
if $1[keyPath: keyPath]
should be ordered after $0[keyPath: keyPath]
. When you replace those values with the actual values from the array, based on the predicate, you see how it’s similar to something like sorted(by: { $0.rating > $1.rating })
.
Now that we’re here, let’s have some more fun with keypaths. Check out the following function:
extension Array {
func column<Value>(_ keyPath: KeyPath<Element, Value>) -> [Value] {
return map { $0[keyPath: keyPath] }
}
}
games.column(\Videogame.title)
// ["Cyberpunk 2077", "Fallout 4", "The Outer Worlds", "RAGE", ···]
What’s going on here? The column(_:)
function will return values for a specific property of objects in an array, kinda like one column of a spreadsheet. In the above code, we’re getting the values for the keypath (i.e., property) \Videogame.title
from the games
array.
The map(_:) function is implicitly called on self
, i.e. on the current array. It essentially maps the array of objects, to an array of specific values, based on the keypath. In the above code, $0[keyPath: keyPath]
is equal to individual strings for title
.
In the above code, Value
and Element
are generic placeholders. They’re not actual Swift types, but they’re merely placeholders that get switched out when you compile the code. The generic function column(_:)
requires that the type of the array that’s returned, i.e. [Value]
, is the same as the type of the property in the keypath. So when the property title
has type String
, the returned array has type [String]
. When you use \Videogame.rating
as the keypath, the returned array must have type… [Double]
!
Alright, let’s write some more code. What about reversing a specific property on an array of objects? We can do that with keypaths. Check this out:
extension Videogame {
mutating func reverse(_ keyPath: WritableKeyPath<Self, String>) {
self[keyPath: keyPath] = String(self[keyPath: keyPath].reversed())
}
}
With the above function, we can reverse any property of the Videogame
struct that’s a string and writable. The type WritableKeyPath<Self, String>
refers to any property that’s declared with var
, of type Self
, which is a reference to Videogame
, and property type String
.
Here’s how it works:
var game = Videogame(title: "Cyberpunk 2077", published: "2020", rating: 999)
game.reverse(\Videogame.title) // 7702 knuprebyC
And with little effort, we can reverse any other String
property too:
game.reverse(\Videogame.published) // 0202
OK, OK, one more… Check out this extension for Array
:
extension Array
{
func find<Value: Comparable>(where keyPath: KeyPath<Element, Value>, equals value: Value) -> Element?
{
for item in self {
if item[keyPath: keyPath] == value {
return item
}
}
return nil
}
}
Here’s how you’d use it:
let games = [···]
if let game = games.find(where: \Videogame.published, equals: "2011") {
print(game.title) // Output: RAGE
}
With the find(where:equals:)
we can search an array of objects, and find an item for which a property is equal to a given value. The find(···)
function relies on a keypath to select the property that we want to match with a value. The difference with using a function like firstIndex(where:), is that the above function works with any property/keypath. Neat!
These examples are a bit contrived of course – unless you actually want to reverse string properties at scale – but they do show how a little programming around types and properties goes a long way towards making flexible, composable code. Awesome!
Keypaths are one of those Swift tools that you didn’t know you needed. They help you write more flexible Swift code, and once you’ve abstracted some of it away, they’re quite fun to work with!
Here’s what we’ve discussed in this tutorial:
Want to learn more? Check out these resources: