6

What is a KeyPath in Swift

 2 years ago
source link: https://sarunw.com/posts/what-is-keypath-in-swift/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

What is a KeyPath in Swift

11 Oct 2021 ⋅ 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.

codeshot.png 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.
2Address 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.
4Permission 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>.

Like most things in Swift, type names can be omitted in contexts where type can be inferred.

In the following example, type name can be inferred from the explicit variable type, so we can leave the type name blank. Notice that we still need \ and ..

// \String.debugDescription => \.debugDescription
let stringDebugDescription: KeyPath<String, String> = \.debugDescription

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.

And two writable key paths.

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

We won't talk about ParialKeyPath and AnyKeyPath here since it is another type-erased variation of KeyPath.

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: "[email protected]",
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.

WritableKeyPath can use on both let and var for reference types.

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: "[email protected]",
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.

We use the short form of closure ($0) in the above example. You can read more about this topic in Different ways to pass a closure as an argument in Swift
.

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.

codeshot.png 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).


You may also like

7 ways to pass a closure as an argument in Swift

There are many ways we can satisfy a closure argument. Some of them do not even look like closure. Let's learn all of them in this article.

Swift
Setting default values for NSUserDefaults

NSUserDefaults is a go-to database for saving users' preferences. Learn how to populate it with your default values.

Swift

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?

← Home


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK