Comprehensive Guide to Mastering KeyPath in Swift

Published on

Get weekly handpicked updates on Swift and SwiftUI!

In the world of Swift, KeyPath is a powerful yet often underestimated feature. Many developers use it inadvertently in their daily programming without fully realizing its potential and importance. This article aims to delve deeply into the functional characteristics of KeyPath, revealing its unique charm in Swift programming, and helping you transform it into a powerful assistant in your development process.

What Is KeyPath

KeyPath is a powerful feature introduced in Swift 4, used to reference (represent) properties or subscripts within a type.

It has the following characteristics:

  • Independent of Specific Instances: KeyPath describes the access path from a certain type to its properties or subscripts, independent of any specific object instance. It can be seen as a static reference that precisely locates the position of a specific property or subscript within a type. This abstraction can be understood by analogy: describing “the left front wheel of my car” is based on a specific instance, while KeyPath is more like “the left front wheel of a car”—a type-based, universally applicable description. This design makes KeyPath particularly useful in generic programming and metaprogramming because it allows us to operate on the structure of a type without knowing the specific instance.
  • Limited to Describing Properties: KeyPath can only be used to describe properties or subscripts within a type and cannot be used to reference methods.
  • Thread-Safe: Although KeyPath itself is not marked as Sendable, it is designed to be immutable and can therefore be safely passed between threads. However, the thread safety of the data accessed using KeyPath still needs to be considered separately.
  • Compile-Time Type Checking: KeyPath provides compile-time type checking to ensure that access to properties is type-safe, thus avoiding runtime type errors.
  • Part of Metaprogramming: KeyPath is an important part of Swift metaprogramming. It allows developers to dynamically access properties in a type-safe manner, achieving high flexibility and generality in code.
  • Conforms to Hashable and Equatable Protocols: KeyPath conforms to the Hashable and Equatable protocols, which allows them to be used as keys in dictionaries or stored in sets, expanding their usage scenarios.
  • Rich Variants: KeyPath is actually a general term for a family of types, including KeyPath, WritableKeyPath, and ReferenceWritableKeyPath, among others. While these variants essentially all describe access paths to properties, they are each suitable for different scenarios.
  • Composable: KeyPath has strong composability, allowing developers to chain multiple KeyPaths together. This feature makes it easy to express and access deeply nested properties.

Basic Usage

Declaration

Swift does not provide a public initializer for KeyPath; developers need to declare it using literal syntax:

Swift
struct People {
  var name: String = "fat"
  var age: Int = 100
  var addresses: [String] = ["world"]
}

// Describes the path to access the 'name' property in People type
let namePath = \People.name

// Describes the path to access the first element of the 'addresses' property in People type
let firstAddressPath = \People.addresses[0]

The declaration syntax of KeyPath is to add a backslash \ before Type.property.

Reading Values via KeyPath

Swift automatically generates a subscript[keyPath:] subscript method for every type, allowing us to access property values in a specific instance via KeyPath:

Swift
let people1 = People()
print(people1[keyPath: firstAddressPath]) // "world"

var people2 = People()
people2.name = "bob"
print(people2[keyPath: namePath]) // "bob"

Setting Values via KeyPath

Similar to reading values, setting values is also done via subscript[keyPath:]:

Swift
var people = People() // Use 'var' to declare value types

people[keyPath: namePath] = "bob"
print(people[keyPath: \.name]) // "bob"

There are some requirements and limitations when setting values, which will be discussed in detail later.

Using KeyPath as a Parameter

Another important feature of KeyPath is that it can be passed as a parameter, allowing us to operate on instance properties without knowing the specific property name:

Swift
struct People {
  var name: String = "fat"
  var age: Int = 100
  var addresses: [String] = ["world"]

  // Accepts a KeyPath where Root is 'People', Value is 'String'
  func getInfo(keyPath: KeyPath<Self, String>) -> String {
    self[keyPath: keyPath]
  }
}

print(people.getInfo(keyPath: \.name)) // "fat"

Members of the KeyPath Family

Before delving into the details of KeyPath, we first need to understand that KeyPath usually does not refer to a single type but is a family that includes five different types.

Family Members

The type hierarchy of the KeyPath family is as follows:

- AnyKeyPath
    - PartialKeyPath<Root>
        - KeyPath<Root, Value>
            - WritableKeyPath<Root, Value>
                - ReferenceWritableKeyPath<Root, Value>

1. AnyKeyPath

  • The base class of all types in the KeyPath family.
  • Does not specify Root or Value types.
  • Read-only access; does not allow write operations.
  • The biggest feature is that it does not use any generics, so it can generalize all KeyPath types.

2. PartialKeyPath<Root>

  • Specifies the Root type but does not specify the Value type.
  • Read-only access; does not allow write operations.
  • Uses one generic (Root); can be used for partially specialized KeyPaths.

3. KeyPath<Root, Value>

  • Specifies both Root and Value types.
  • Read-only access; does not allow write operations.
  • Uses two generics, providing a specific mapping from Root to Value.

4. WritableKeyPath<Root, Value>

  • Allows read and write operations on properties.
  • Applicable to value types and reference types.
  • Is the writable version of KeyPath.

5. ReferenceWritableKeyPath<Root, Value>

  • Specifically used for reference types.
  • Allows read and write operations on properties.
  • Provides additional performance optimizations, especially support for let properties.

From the above structure, we can see that the inheritance of the KeyPath family has the following characteristics:

  • The degree of generic specialization gradually increases, from no generics to using two generics.
  • Access permissions gradually increase, from read-only to read-write.

Declaration and Conversion

Although the KeyPath family has multiple types, their declaration methods are very uniform, all declared via literal syntax:

Swift
let namePath = \People.name // Inferred as WritableKeyPath<People, String>

In the example above, why is the type of namePath inferred as WritableKeyPath<People, String>?

This is because the Swift compiler will automatically infer the most specialized type in the KeyPath family based on the kind of type (value type or reference type) and the read-write status of the property (read-only or read-write). This inference ensures that KeyPath can precisely match the characteristics of the property, providing more accurate access or manipulation capabilities.

In the above example, People is a value type, and name is a read-write property, so it is inferred as WritableKeyPath<People, String>. Here, People corresponds to Root in the generic, and String corresponds to Value.

Other inference examples:

Swift
struct People {
  let name: String
}
let peopleNamePath = \People.name // Inferred as KeyPath<People, String>, because 'name' is read-only

class Item {
  var firstName: String
  var lastName: String
  var name: String {
    get { firstName }
    set { firstName = newValue }
  }
}

// Inferred as ReferenceWritableKeyPath<Item, String>, because 'Item' is a reference type and 'firstName' is writable
let firstNamePath = \Item.firstName

// Inferred as ReferenceWritableKeyPath<Item, String>, because 'name' has a setter and is writable
let itemNamePath = \Item.name

// Inferred as KeyPath<Item, Int>, because 'count' is a read-only computed property of 'String'
let firstNameCountPath = \Item.firstName.count

Therefore, if we want to explicitly declare a KeyPath of a specific type, we can specify the type explicitly at declaration:

Swift
// Explicitly declare as WritableKeyPath
let firstNamePath: WritableKeyPath<Item, String> = \Item.firstName // Declared as a type higher than ReferenceWritableKeyPath
// Explicitly declare as KeyPath
let itemNamePath: KeyPath<Item, String> = \Item.name // Declared as a higher-level type than ReferenceWritableKeyPath
// Directly declare as AnyKeyPath to avoid using generics
let firstNameCountAnyPath: AnyKeyPath = \Item.firstName.count

// ❌ Declaration fails because 'count' is not writable
let firstNameCountAnyPath: WritableKeyPath<Item, Int> = \Item.firstName.count 

Conversion

In the KeyPath family, more specialized types can be converted to more generalized types. For example:

Swift
let firstNameAnyPath: AnyKeyPath = firstNamePath
let itemNameAnyPath: PartialKeyPath<Item> = itemNamePath

This conversion method is different from the conversion mechanism between parent and child classes in Swift; as long as the types and property characteristics meet the conditions, we can freely convert types within the KeyPath hierarchy. For example, converting AnyKeyPath to a more specialized type:

Swift
let firstNameCountAnyPath: AnyKeyPath = \Item.firstName.count

// Successfully convert to KeyPath
let firstNameCountPath1 = firstNameCountAnyPath as! KeyPath<Item, Int>

// Conversion fails because 'count' is not writable
let firstNameCountPath2 = firstNameCountAnyPath as! WritableKeyPath<Item, Int>

The reason this conversion is possible is that even the most generalized AnyKeyPath type internally retains all the information of the specialized type.

AnyKeyPath: Not Just a Type-Erasure Tool

When seeing AnyKeyPath, many developers may think of type-erasure tools like AnyHashable, AnyPublisher, AnyView, etc. Although AnyKeyPath does have type-erasure characteristics (especially generic erasure), it is essentially more than just a type-erasure tool. AnyKeyPath is a base class containing comprehensive information, and its subclasses like PartialKeyPath and KeyPath further provide additional type safety and compile-time checks by adding generic constraints. This design cleverly combines runtime flexibility with compile-time safety, making the key path system both powerful and safe.

Like other tools with type-erasure functionality, AnyKeyPath is particularly useful in scenarios where generic constraints need to be avoided, such as declaring arrays or dictionaries:

Swift
let keys: [AnyKeyPath] = [\Item.name, \People.age]

And PartialKeyPath with only one generic is suitable for scenarios where the Root type is already clear. On the one hand, it constrains the key paths that can be used; on the other hand, since the Root type is provided in the context, developers can input key paths more conveniently:

Swift
let keys: [PartialKeyPath<People>] = [\.name, \.age]

Composing KeyPaths

KeyPath can easily express deeply nested properties. For example:

Swift
struct A {
  var b: B
}

struct B {
  var name: String
}

let namePath = \A.b.name // WritableKeyPath<A, String>
let nameCountPath = \A.b.name.count // KeyPath<A, Int>

For a KeyPath type with two generic constraints, regardless of the depth of the path, the rules of Root and Value always remain consistent:

  • Root: The starting type of the access path.
  • Value: The type of the property at the end of the access path.

Therefore, the inferred type of \A.b.name.count is KeyPath<A, Int>, because the type of the count property is Int.

In many cases, we don’t need to directly declare deeply nested paths; we can create a new path by combining two KeyPaths via appending(path:):

Swift
// WritableKeyPath<A, B>
let bPath = \A.b
// KeyPath<B, Int>
let bNameCountPath = \B.name.count
// KeyPath<A, Int>
let nameCountPath1 = bPath.appending(path: bNameCountPath)

The basic requirement for combining KeyPaths is that the Root type of the appended KeyPath must match the Value type of the KeyPath being appended to. The generics of the combined KeyPath are: the Root of the original KeyPath and the Value of the appended KeyPath.

When using AnyKeyPath or PartialKeyPath, combining with other KeyPath types will return an optional KeyPath; if the types do not match, it will return nil at runtime:

Swift
// AnyKeyPath
let bPath: AnyKeyPath = \A.b
// KeyPath<B, Int>
let bNameCountPath = \B.name.count
// AnyKeyPath?
let nameCountPath1 = bPath.appending(path: bNameCountPath)

// Root is Item
let itemNamePath = \Item.name
// nil
let combinePath = bPath.appending(path: itemNamePath)

It should be noted that not all different types of KeyPaths can be successfully combined. For example, attempting to combine using KeyPath.appending(path: AnyKeyPath) will fail, even though AnyKeyPath actually contains all the information needed for the combination. In practical use, developers should perform additional tests to ensure type compatibility.

WritableKeyPath vs ReferenceWritableKeyPath

Both WritableKeyPath and ReferenceWritableKeyPath can be used to represent a writable property path. The main differences between the two are as follows:

  1. Applicable Types:

    WritableKeyPath is applicable to both value types and reference types, while ReferenceWritableKeyPath can only be used for reference types.

    Swift
    struct A {
      var name: String = ""
    }
    
    // WritableKeyPath<A, String>
    let aNamePath = \A.name
    
    class B {
      var name: String = ""
    }
    
    // WritableKeyPath<B, String>
    let bNamePath: WritableKeyPath<B, String> = \B.name
  2. Instance Declaration Requirements:

    When using WritableKeyPath, the instance must be declared with var to modify properties; when using ReferenceWritableKeyPath, even if the instance is declared with let, the property can still be modified.

    Swift
    // WritableKeyPath<A, String>
    let aNamePath = \A.name
    let a = A()
    a[keyPath: aNamePath] = "fat" // Compilation error because 'a' is declared with 'let'
    
    // ReferenceWritableKeyPath<B, String>
    let bNamePath: ReferenceWritableKeyPath<B, String> = \B.name
    let b = B()
    b[keyPath: bNamePath] = "bob" // Executes correctly even though 'b' is declared with 'let'
  3. Specifically Designed for Reference Types:

    ReferenceWritableKeyPath is specifically designed for properties of reference types. It is a special subclass of WritableKeyPath, providing additional guarantees and potential optimizations for reference types.

    Swift
    func strLength<T>(obj: T, strKeyPath: ReferenceWritableKeyPath<T, String>) -> Int {
      obj[keyPath: strKeyPath].count
    }
    
    strLength(obj: b, strKeyPath: \.name) // Runs normally; 'B' is a reference type
    strLength(obj: a, strKeyPath: \.name) // Compilation error; 'A' is a value type

Hashable and Equatable

Although many types conform to the Hashable and Equatable protocols, the members of the KeyPath family have their own uniqueness in implementing these protocols.

Because different levels of types in the KeyPath family (such as KeyPath and AnyKeyPath) actually share the same internal information, cross-type comparison is possible:

Swift
let nameKeyPath: KeyPath<People, String> = \.name
let nameAnyKeyPath: AnyKeyPath = \People.name

// Compare KeyPath<People, String> and AnyKeyPath
print(nameKeyPath == nameAnyKeyPath) // true

Similarly, their hashValue calculations are based on the same internal information:

Swift
print(nameKeyPath.hashValue == nameAnyKeyPath.hashValue) // true

This feature makes it more convenient for developers when comparing KeyPaths or using KeyPaths as dictionary keys. Regardless of the type of KeyPath used, as long as they describe the same path, they can be used as the same key:

Swift
var keysCount: [AnyKeyPath: Int] = [:]

keysCount[nameKeyPath, default: 0] = keysCount[nameKeyPath, default: 0] + 1 // KeyPath<People, String>
keysCount[nameKeyPath, default: 0] = keysCount[nameAnyKeyPath, default: 0] + 1 // AnyKeyPath
print(keysCount[\People.name]) // Optional(2)

\.self

When declaring a KeyPath, if you want Value to represent the type itself, you can use .self.

Swift
var texts = ["b", "o", "b"]
// WritableKeyPath<[String], [String]>
let array = \[String].self
texts[keyPath: array] = ["f", "a", "t"]
print(texts) // ["f", "a", "t"]

var numbers = [3, 5, 6]
// WritableKeyPath<[Int], Int>
let firstElement = \[Int].self[0] // `self` can be omitted, written as `\[Int].[0]`
numbers[keyPath: firstElement] = 10
print(numbers) // [10, 5, 6]

Many developers use similar code in SwiftUI views:

Swift
struct DemoView: View {
    let numbers = [3, 5, 6, 8, 5, 3]
    var body: some View {
        VStack {
            ForEach(numbers, id: \.self) { number in
                Text(number, format: .number)
            }
        }
    }
}

At this point, the constructor of ForEach is as follows:

Swift
public init<Data: RandomAccessCollection, ID: Hashable>(
    _ data: Data,
    id: KeyPath<Data.Element, ID>,
    @ViewBuilder content: @escaping (Data.Element) -> Content
)

By specifying id: \.self, we use each element in the array as the unique identifier (ID) for ForEach. However, if there are duplicate elements in the array, the view identifiers within ForEach will conflict, which becomes particularly problematic when adding or deleting elements. Therefore, the best practice is to have the elements conform to the Identifiable protocol instead of directly using the elements themselves as IDs to avoid potential conflicts.

Performance

In previous versions, I believed that accessing properties via KeyPath had performance comparable to direct property access. However, after the article was published, Rick van Voorden shared his research on KeyPath performance with me via email. According to his tests, the performance of KeyPath currently lags behind direct property access (especially in reference types). He believes this may be due to the lack of certain necessary optimizations in the current implementation.

He pointed out some code sections that need attention:

  1. KeyPath.swift lines 334-341
  2. KeyPath.swift lines 405-414
  3. KeyPath.swift lines 2389-2405

Perhaps in future versions of Swift, KeyPath will provide the same performance when accessing reference types and structs.

Rick van Voorden is the developer of the Swift-CowBox macro. This macro simplifies the process of implementing Copy-On-Write (COW) for custom types, reducing the burden of writing repetitive code.

Is KeyPath Important?

More and more developers are gradually realizing the importance and convenience of KeyPath, and it has now been widely used in system frameworks and third-party frameworks.

For example, high-order functions that previously used closures can now be implemented more elegantly with KeyPath:

Swift
let peoples: [People] = []
// Traditional way
let names1 = peoples.map { $0.name }
// KeyPath-based way
let names2 = peoples.map(\.name)

In the new predicate macros, KeyPath also plays an important role. Because of its instance-independent characteristics, it can effectively describe predicate conditions:

Swift
let predicate = #Predicate<Settings> {
    $0.name == "abc"
}

// After macro expansion
Foundation.Predicate<Settings>({
    PredicateExpressions.build_Equal(
        lhs: PredicateExpressions.build_KeyPath(
            root: PredicateExpressions.build_Arg($0),
            keyPath: \.name
        ),
        rhs: PredicateExpressions.build_Arg("abc")
    )
})

In the Observation framework, KeyPath is also used to trigger property change notifications:

Swift
internal nonisolated func withMutation<Member, T>(keyPath: KeyPath<Settings, Member>, _ mutation: () throws -> T) rethrows -> T {
  try _$observationRegistrar.withMutation(of: self, keyPath: keyPath, mutation)
}

Combining KeyPath with @dynamicMemberLookup is also a common usage scenario. This approach ensures the encapsulation of internal data while providing flexible and type-safe property access:

Swift
@dynamicMemberLookup
final class Store<State>: ObservableObject {
    @Published private var state: State
    subscript<T>(dynamicMember keyPath: KeyPath<State, T>) -> T {
        state[keyPath: keyPath]
    }
    ...
}

let userName = store.user // Corresponds to store.state.user

Many property wrappers provided by Apple’s official frameworks (such as @ObservedObject and @StateObject) exemplify this application.

KeyPath makes developers’ code more elegant and safe, with stronger generality. It is an important manifestation of the gradual perfection and strength of the Swift language’s functionality.

Conclusion

KeyPath is one of the core features in the Swift language, providing us with an elegant, type-safe, and flexible way to reference and manipulate properties of types. It not only offers powerful capabilities when accessing nested properties and handling generic data but also ensures code safety and stability through its instance-independent characteristics and compile-time type checking.

The introduction of KeyPath marks a further development of the Swift language toward type safety, flexibility, and performance optimization. It provides developers with tools to manage complex data structures in large projects. In the future, KeyPath is likely to continue to play a role in more frameworks and scenarios, becoming an important embodiment of the Swift language’s maturity and strength.

Weekly Swift & SwiftUI insights, delivered every Monday night. Join developers worldwide.
Easy unsubscribe, zero spam guaranteed