What is a KeyPath in Swift

⋅ 12 min read ⋅ Swift KeyPath

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.

You can easily support sarunw.com by checking out this sponsor.

Sponsor sarunw.com and reach thousands of iOS developers.

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.

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.

  1. Read-only key paths.
  2. Writable key paths (Can read and write).

We have three read-only key paths.

  1. KeyPath
  2. ParialKeyPath
  3. AnyKeyPath

And two writable key paths.

  1. WritableKeyPath
  2. ReferenceWritableKeyPath

I will only focus on three basic types of key paths in this article.

  1. KeyPath: A read-only access to a property. Root type can be both value/reference semantics.
  2. WritableKeyPath: Provides read-write access to a mutable property with value semantics (such as struct and enum).
  3. ReferenceWritableKeyPath: Provides reading and writing to a mutable property with reference semantics (such as class).

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 or subscript with only get), KeyPath is inferred.
  • If it is mutable (such as var or subscript with get/set).
    • With a root of value types (such as struct and enum), WritableKeyPath is inferred.
    • With a root of reference types (such as class), ReferenceWritableKeyPath is inferred.

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.

You can't use WritableKeyPath to write to a let constant.
You can't use WritableKeyPath to write to a let constant.

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.

Sponsor sarunw.com and reach thousands of iOS developers.

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 Share
Previous
Should 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?

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

← Home