What is a KeyPath in Swift
Table of Contents
KeyPath is a type that represent a references to properties. At some level, it is a kind of metaprogramming. It might not be obvious how we can fit this language feature into our code.
By learning about the key path, you open up yourself to an opportunity to improve your existing API or even create a new one that you don't aware you can do it. You might not get it right away, but as you see more and more use cases, in the end, you might be able to find an opportunity to use them in the future.
Let's see what KeyPath is and what it has to offer.
What is KeyPath
KeyPath is a type that represent a references to properties and subscripts. Since it is a type, you can store it in a variable, pass it around, or even perform an operation on a key path. We can use a key path to get/set their underlying values at a later time.
Key paths might sound advance and difficult, but the concept might be simpler than you think.
Let's create a few structs, which we will use as an example.
// 1
struct User {
let name: String
let email: String
let address: Address?
let role: Role
}
// 2
struct Address {
let street: String
}
// 3
enum Role {
case admin
case member
case guest
var permissions: [Permission] {
switch self {
case .admin:
return [.create, .read, .update, .delete]
case .member:
return [.create, .read]
case .guest:
return [.read]
}
}
}
// 4
enum Permission {
case create
case read
case update
case delete
}
1 We declare a User
struct which stores name
, email
, address
, and role
.
2 Address
is a struct that keeps the information about the address. For brevity, we only have a street
name.
3 We have all possible roles as an enum, Role
. It also holds a computed property that returns an array of Permission
that particular have.
4 Permission
is an enum of all possible actions a user can make.
You can easily support sarunw.com by checking out this sponsor.
Debug 10x faster with Proxyman: Your ultimate tool to capture HTTPs requests/ responses, natively built for your iPhone and macOS. Special deal for Black Friday: Get 30% off for all Proxyman licenses with code “BLACKFRIDAY2024”.
Key Path Expression
Key Path expressions have the following form:
\type name.path
Type Name
The type name
is the name of a concrete type, including any generic parameters if the types have one.
Here is an example.
// 1
let stringDebugDescription = \String.debugDescription
// KeyPath
// 2
let userRole = \User.role
// KeyPath
// 3
let firstIndexInteger = \[Int][0]
// WritableKeyPath<[Int], Int>
// 4
let firstInteger = \Array .first
// KeyPath<[Int], Int?>
1 Key path which type is a String
.
2 Key path which type is our custom struct, User
.
3, 4 We can reference array by the full type name (Array
) or shortened form ([]
), but you have to also specify its generic type, [Int] and Array<Int>
.
Path
The path
can be property names, subscripts, optional-chaining expressions, and forced unwrapping expressions. Basically, everything that we usually use when reference value on an instance object/struct.
// 1
let userName = \User.name
// KeyPath
// 2
let firstIndexInteger = \[Int][0]
// WritableKeyPath<[Int], Int>
// 3
let streetAddress = \User.address?.street
// KeyPath
// 4
let forceStreetAddress = \User.address!.street
// KeyPath
1 Key path which path is a property name.
2 Key path which path is a subscript.
3 Key path which path is an optional-chaining to a property.
4 Key path which path is forced unwrapping to a property name.
Path can be repeated as many times as needed, and it can also refer to a computed property name.
let userRolePermissions = \User.role.permissions
// KeyPath<User, [Permission]>
let firstUserRolePermissions = \User.role.permissions[0]
// KeyPath<User, Permission>
Syntax
I think the syntax of a key path should become clearer to you at this point. Since KeyPath
is a way to define a reference to properties and subscripts, that means it can use any expression we use to reference properties and subscripts from an instance.
To convert a normal reference to a key path, we replace any class/struct instance with a backslash (\
) followed by that instance type.
let streetValue = user.address?.street
// KeyPath version referencing the same value.
let streetKeyPath = \User.address?.street
Types of KeyPath
Swift has five key path types, but we can categorize them into two groups based on their functions.
- Read-only key paths.
- Writable key paths (Can read and write).
We have three read-only key paths.
And two writable key paths.
I will only focus on three basic types of key paths in this article.
KeyPath
: A read-only access to a property. Root type can be both value/reference semantics.WritableKeyPath
: Provides read-write access to a mutable property with value semantics (such asstruct
andenum
).ReferenceWritableKeyPath
: Provides reading and writing to a mutable property with reference semantics (such asclass
).
How can key path type inferred
In the last section, when we construct key paths, you can see that we only get KeyPath
and WritableKeyPath
.
An example from previous section.
let firstIndexInteger = \[Int][0]
// WritableKeyPath<[Int], Int>
let firstInteger = \Array<Int>.first
// KeyPath<[Int], Int?>
How can the key path type be inferred? The answer to this is easy. It infers from the properties/subscripts and a root type.
- If properties or subscripts are read-only (such as
let
orsubscript
with onlyget
),KeyPath
is inferred. - If it is mutable (such as
var
orsubscript
withget/set
).- With a root of value types (such as
struct
andenum
),WritableKeyPath
is inferred. - With a root of reference types (such as
class
),ReferenceWritableKeyPath
is inferred.
- With a root of value types (such as
Once you know the rules, let's get back to our example.
We declare every property with let
, so we get KeyPath
as a result.
let userRole = \User.role
// KeyPath<User, Role>
let streetAddress = \User.address?.street
// KeyPath<User, String?>
first
and debugDescription
are read-only computed properties, so we also get KeyPath
as a result.
let stringDebugDescription = \String.debugDescription
// KeyPath<String, String>
let firstInteger = \Array<Int>.first
// KeyPath<[Int], Int?>
We get WritableKeyPath
when reference array subscript
because it is a read-write subscript (get/set
).
subscript(index: Int) -> Element { get set }
let firstIndexInteger = \[Int][0]
// WritableKeyPath<[Int], Int>
If we change the name
property to var
, we will get WritableKeyPath
when reference \User.name
.
struct User {
var name: String
}
\User.name
// WritableKeyPath<User, String>
If we change User
to class
, key path to var
and let
will be ReferenceWritableKeyPath
and KeyPath
, respectively.
class User {
var name: String
let email: String
init(name: String, email: String) {
self.name = name
self.email = email
}
}
\User.name
// ReferenceWritableKeyPath<User5, String>
\User.email
// KeyPath<User, String>
Now that we know how to create key paths. Let's see what we can do with them.
Usage
A key path refers to a property or subscript of a type, so its only usage is to read/write to that property/subscript using the key path.
Access a value
To access a value using a key path, pass a key path to the subscript(keyPath:)
subscript, which is available on all types. You can use it to read or write based on the type of a key path and an instance.
Her is an example of using key path to read/write to user.role
.
var user = User(
name: "Sarunw",
email: "sarunw@example.com",
address: nil,
role: .admin)
let userRoleKeyPath = \User.role
// WritableKeyPath<User, Role>
// 1
let role = user[keyPath: userRoleKeyPath]
print(role) // admin
// 2
user[keyPath: userRoleKeyPath] = .guest
print(user.role) // guest
1 Use keypath to read the role value.
2 Use keypath to set the role value.
One thing to note here is that even with WritableKeyPath
, your struct still needs to be var
to be able to write. Try to set a new value on a let
value would cause a compile error.
Caveats
Constructing a key path using unsafe expressions can cause the same runtime error as using them on an instance.
Here is an example using forced unwrapping expressions (!
) and array subscript(index: Int)
in key paths.
let fourthIndexInteger = \[Int][3]
let integers = [0, 1, 2]
print(integers[keyPath: fourthIndexInteger])
// Fatal error: Index out of range
let user = User(
name: "Sarunw",
email: "sarunw@example.com",
address: nil,
role: .admin)
let forceStreetAddress = \User.address!.street
print(user[keyPath: forceStreetAddress])
// Fatal error: Unexpectedly found nil while unwrapping an Optional value
Identity Key Path
We also have a special path that can refer to a whole instance instead of a property. We can create one with the following syntax, \.self
.
The result of the identity key path is the WritableKeyPath
of the whole instance, so you can use it to access and change all of the data stored in a variable in a single step.
var foo = "Foo"
// 1
let stringIdentity = \String.self
// WritableKeyPath<String, String>
foo[keyPath: stringIdentity] = "Bar"
print(foo) // Bar
struct User {
let name: String
}
var user = User(name: "John")
// 2
let userIdentity = \User.self
// WritableKeyPath<User, User>
user[keyPath: userIdentity] = User(name: "Doe")
print(user) // User(name: "Doe")
1 Identity key path to String.
2 Identity key path to User.
Use Cases
Key paths seem like another way of reading and writing value out of an instance. But the fact that we can treat an ability to read/write a value in the form of a variable makes the use cases broader than read and write.
It is okay if you can't think of any use cases of key paths. As I mentioned initially, it is a kind of metaprogramming that is needed for some specific scenario.
It is quite hard to tell you exactly where you should use the key paths. I think it is easier to show you where they are used. If you have seen enough use cases, I think you will eventually know where you can use them (or don't).
Here are some places where key paths are used in real API.
Key paths as protocols alternative
In SwiftUI, we can create views from a collection of Identifiable
data. The only requirement of the Identifiable
protocol is a Hashable
variable named ID
.
struct User: Identifiable {
let name: String
// 1
var id: String {
return name
}
}
let users: [User] = [
User(name: "John"),
User(name: "Alice"),
User(name: "Bob"),
]
struct SwiftUIView: View {
var body: some View {
ScrollView {
ForEach(users) { user in
Text(user.name)
}
}
}
}
1 Use name to uniquely identify user. This is for demonstration only, you should use something more unique for an ID, or bad things will happen with your list.
Identifiable
is a protocol to uniquely identify an item in a list. SwiftUI also provides an alternative initializer using a key path.
KeyPath
Instead of forcing data type to conform Identifiable
protocol, this alternative initializer let data type specified a path to its underlying data identity.
// 1
struct User {
let name: String
}
struct SwiftUIView: View {
var body: some View {
ScrollView {
// 2
ForEach(users, id: \.name) { user in
Text(user.name)
}
}
}
}
1 User no longer conform to Identifiable
protocol.
2 We specify path to property that can uniquly identify User
struct.
Instead of using a protocol to define a common interface for getting some value, we can use a key path to inject that value instead. Keypath provided a way to transfer read access to other functions.
The interesting point here is the ability to reference to read/write access resulting in the equivalent functionality as Identifiable
protocol. The scope of key paths can be broader than just read/write.
Key paths as functions
We can also look at a key path in the form of function.
The key path expression \Root.value
can represent as a function with the following signature (Root) -> Value
. Let's see how this conversion work.
Example
In this example, we try to map user names out of an array of users.
map(_:)
has the following signature. It accepts a transform parameter closure with array element (Element
) as argument and return type that you want to transform to (T
).
func map<T>(_ transform: (Element) throws -> T) rethrows -> [T]
Let try it without a key path.
struct User {
let name: String
}
let users = [
User(name: "John"),
User(name: "Alice"),
User(name: "Bob")
]
let userNames = users.map { user in
return user.name
}
// ["John", "Alice", "Bob"]
In this example, map(_:)
accept a parameter of function (Element) -> Value
. Based on our claim, we should be able to use a key path expression \Element.Value
instead. Let's try to create a new override of a map that takes a key path instead.
extension Array {
func map<Value>(_ keyPath: KeyPath<Element, Value>) -> [Value] {
return map { $0[keyPath: keyPath] }
}
}
let userNames = users.map(\.name)
// ["John", "Alice", "Bob"]
As you can see, we can create an equivalent implementation for a function that expected (Root) -> Value
with a key path of \Root.Value
. In Swift 5.2, we don't even have to do the conversion ourselves. This functionality is built right into the Swift under this proposal.
As a result, a key path expression \Root.value
can use wherever functions of (Root) -> Value
are allowed.
Again, a key path shows it can do much more than access a value. It is even replacing a function call in this case.
You can easily support sarunw.com by checking out this sponsor.
Debug 10x faster with Proxyman: Your ultimate tool to capture HTTPs requests/ responses, natively built for your iPhone and macOS. Special deal for Black Friday: Get 30% off for all Proxyman licenses with code “BLACKFRIDAY2024”.
Conclusion
The concept and syntax of a key path are easy to understand, but the difficult part is to know when and where to use it.
On the surface, it is just a way to access a value, but as you can see in the use cases, scopes of a key path can be broader than that.
I know there must be more interesting use cases that we can use key paths. If you have any interesting use cases, please let me know on Twitter @sarunw (My DM is open).
Read more article about Swift, KeyPath, or see all available topic
Enjoy the read?
If you enjoy this article, you can subscribe to the weekly newsletter.
Every Friday, you'll get a quick recap of all articles and tips posted on this site. No strings attached. Unsubscribe anytime.
Feel free to follow me on Twitter and ask your questions related to this post. Thanks for reading and see you next time.
If you enjoy my writing, please check out my Patreon https://www.patreon.com/sarunw and become my supporter. Sharing the article is also greatly appreciated.
Become a patron Buy me a coffee Tweet ShareShould every if statement has an else clause
Every switch statement has a default case. Every do-catch statement has a catch clause. Should every if statement has an else clause?
How to make a custom button style supports leading dot syntax in SwiftUI
In Swift 5.5, we can apply button style using leading dot syntax, .buttonStyle(.plain), instead of a full name of a concreate type, .buttonStyle(PlainButtonStyle()). Let's see how we can make our custom button style support this.