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, whileKeyPath
is more like “the left front wheel of a car”—a type-based, universally applicable description. This design makesKeyPath
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 asSendable
, it is designed to be immutable and can therefore be safely passed between threads. However, the thread safety of the data accessed usingKeyPath
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
andEquatable
Protocols:KeyPath
conforms to theHashable
andEquatable
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, includingKeyPath
,WritableKeyPath
, andReferenceWritableKeyPath
, 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 multipleKeyPath
s 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:
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
:
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:]
:
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:
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
orValue
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 theValue
type. - Read-only access; does not allow write operations.
- Uses one generic (
Root
); can be used for partially specializedKeyPath
s.
3. KeyPath<Root, Value>
- Specifies both
Root
andValue
types. - Read-only access; does not allow write operations.
- Uses two generics, providing a specific mapping from
Root
toValue
.
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:
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:
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:
// 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:
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:
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:
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:
let keys: [PartialKeyPath<People>] = [\.name, \.age]
Composing KeyPaths
KeyPath
can easily express deeply nested properties. For example:
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 KeyPath
s via appending(path:)
:
// 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 KeyPath
s 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:
// 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 KeyPath
s 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:
-
Applicable Types:
WritableKeyPath
is applicable to both value types and reference types, whileReferenceWritableKeyPath
can only be used for reference types.Swiftstruct 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
-
Instance Declaration Requirements:
When using
WritableKeyPath
, the instance must be declared withvar
to modify properties; when usingReferenceWritableKeyPath
, even if the instance is declared withlet
, 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'
-
Specifically Designed for Reference Types:
ReferenceWritableKeyPath
is specifically designed for properties of reference types. It is a special subclass ofWritableKeyPath
, providing additional guarantees and potential optimizations for reference types.Swiftfunc 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:
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:
print(nameKeyPath.hashValue == nameAnyKeyPath.hashValue) // true
This feature makes it more convenient for developers when comparing KeyPath
s or using KeyPath
s 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:
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
.
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:
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:
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:
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
:
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:
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:
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:
@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.