KeyPath in Swift
In the world of Swift, KeyPath
is a powerful yet often underappreciated feature. Many developers use it unintentionally in their daily coding but fail to fully recognize its potential and importance. This article aims to delve deeper into the functionality features of KeyPath
, revealing its unique charm in Swift programming, and helping you transform it into a valuable assistant in your development process.
What is KeyPath
KeyPath
is a powerful feature introduced by Swift 4, used for referencing (representing) properties or subscripts within types.
It has the following characteristics:
- Independent of specific instances:
KeyPath
describes an access path from a type to its property or subscript, independent of any specific object instance. It can be viewed as a static reference that precisely locates the position of a specific property or subscript within a type. This abstraction can be understood through analogy: "my car's left front wheel" is a description based on a specific instance, whileKeyPath
is more like "car's left front wheel" – a general and universal description based on a type. This design makesKeyPath
particularly useful in generic programming and metaprogramming because it allows us to operate on the structure of types without knowing the specific instances. - Only describes properties:
KeyPath
can only be used to describe properties or subscripts within types, not methods. - Thread-safe: Although
KeyPath
is not marked asSendable
, it is designed to be immutable and can safely be passed between threads. However, the thread safety of data accessed throughKeyPath
still needs to be considered separately. - Compile-time type checking:
KeyPath
provides compile-time type checking, ensuring that property access is type-safe. This prevents runtime type errors. - Part of metaprogramming:
KeyPath
is an essential component of Swift's metaprogramming features. 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 theHashable
andEquatable
protocols, making it possible to use them as dictionary keys or store them in collections, expanding its usage scenarios. - Variants:
KeyPath
is actually a family of types, includingKeyPath
,WritableKeyPath
, andReferenceWritableKeyPath
. Although these variants essentially describe property access paths, they are suited for different contexts. - Composability:
KeyPath
has strong compositional capabilities, allowing developers to concatenate multipleKeyPath
s. This feature makes it easy to express and access deeply nested properties.
Basic Usage
Declaration
Swift does not provide a public constructor for KeyPath
. Developers must declare it using the literal syntax:
struct People {
var name: String = "fat"
var age: Int = 100
var addresses: [String] = ["world"]
}
// Describes the access path to the 'name' property of the 'People' type
let namePath = \.name
// Describes the access path to the first element of the 'addresses' property of the 'People' type
let firstAddressPath = \People.addresses[0]
The declaration syntax for KeyPath
is adding a backslash (\
) before the type and property.
Reading Values through KeyPath
Swift generates a subscript method called subscript[keyPath:]
for every type, allowing us to access specific instance properties using a KeyPath
:
let people1 = People()
print(people1[keyPath: firstAddressPath]) // "world"
var people2 = People()
people2.name = "bob"
print(people2[keyPath: namePath]) // "bob"
Setting Values through KeyPath
Similar to reading values, setting values also requires using the subscript[keyPath:]
method:
var people = People() // Declare a mutable value type
people[keyPath: namePath] = "bob"
print(people[keyPath: \.name]) // "bob"
Setting values has requirements and restrictions that will be discussed in more detail later.
Passing KeyPath as Parameters
KeyPath
has another important feature that it can be passed as a parameter, allowing us to operate on an instance's properties without knowing their specific names:
Swift
struct People {
var name: String = "fat"
var age: Int = 100
var addresses: [String] = ["world"]
// Receive a KeyPath with its Root as People and Value as String
func getInfo(keyPath: KeyPath<Self, String>) -> String {
self[keyPath: keyPath]
}
}
print(people.getInfo(keyPath: \.name)) // "fat"
The Family Members of KeyPath
Before delving into the details of KeyPath
, we first need to understand that KeyPath
is not a single type, but rather a family of types with five different kinds.
Family Members
The hierarchy of the KeyPath
family looks like this:
Shell
- AnyKeyPath
- PartialKeyPath<Root>
- KeyPath<Root, Value>
- WritableKeyPath<Root, Value>
- ReferenceWritableKeyPath<Root, Value>
1. AnyKeyPath
- The base class of all
KeyPath
types. - Does not specify the
Root
orValue
type. - Read-only access is allowed, but write operations are not permitted.
- Its most notable feature is that it does not use any generics, making it a universal type for all
KeyPath
types.
2. PartialKeyPath<Root>
- Specifies the
Root
type but not theValue
type. - Read-only access is allowed, but write operations are not permitted.
- Uses one generic (
Root
) to support partially specializedKeyPath
s.
3. KeyPath<Root, Value>
- Both specifies
Root
andValue
types. - Read-only access is allowed, but write operations are not permitted.
- Uses two generics to provide a specific mapping from
Root
toValue
.
4. WritableKeyPath<Root, Value>
- Allows both read and write operations on properties.
- Supports value types and reference types.
- Is the writable version of
KeyPath
.
5. ReferenceWritableKeyPath<Root, Value>
- Specifically designed for reference type properties.
- Allows both read and write operations.
- Provides additional performance optimizations, particularly for
let
properties.
From this structure, we can see that the inheritance of the KeyPath
family has the following characteristics:
- The degree of generic specialization increases gradually from no generics to using two generics.
- The access permissions increase gradually from read-only to read-write.
Declaration and Conversion
Although there are many types in the KeyPath
family, their declaration syntax is very uniform and uses literal syntax:
Swift
let namePath = \People.name // Inferred as WritableKeyPath<People, String>
In this example, why was namePath
inferred to be a WritableKeyPath<People, String>
?
This is because the Swift compiler will automatically infer the most specialized type in the KeyPath
family based on the property's characteristics (value or reference type) and its read-write status. This inference ensures that the KeyPath
accurately matches the properties' features and provides more accurate access or operation capabilities.
In this example, People
is a value type, and name
is a writable property, so it was inferred to be a WritableKeyPath<People, String>
. Here, People
corresponds to the generic Root
, and String
corresponds to the generic Value
.
Other examples of automatic inference:
Swift
struct People {
let name: String
}
let peopleNamePath = \People.name // Inferred as KeyPath<People, String>, because name is read-onlyclass 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 specific type of KeyPath
, we can do so by specifying the type at declaration time:
Swift
// Explicitly declared as WritableKeyPath
let firstNamePath: WritableKeyPath<Item, String> = \Item.firstName // Declared as a higher level type than ReferenceWritableKeyPath
// Explicitly declared as KeyPath
let itemNamePath: KeyPath<Item, String> = \Item.name // Declared as a higher level type than ReferenceWritableKeyPath
// Directly declared 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, we can convert more specialized types to more general types. For example:
Swift
let firstNameAnyPath: AnyKeyPath = firstNamePath
let itemNameAnyPath: PartialKeyPath<Item> = itemNamePath
This conversion mechanism is different from the parent-child class conversion in Swift, and only works if the type and property attributes meet the conditions. We can freely convert types in the KeyPath
hierarchy, such as converting AnyKeyPath
to a more specialized type:
Swift
let firstNameCountAnyPath: AnyKeyPath = \Item.firstName.count
// Successfully converted to KeyPath
let firstNameCountPath1 = firstNameCountAnyPath as! KeyPath<Item, Int>
// Conversion fails because count is not writable
let firstNameCountPath2 = firstNameCountAnyPath as! WritableKeyPath<Item, Int>
This conversion is possible because even the most general AnyKeyPath
type retains all information about specialized types.
AnyKeyPath, The Non-Generalized Type Erasure Tool
When seeing AnyKeyPath
, many developers may think of types like AnyHashable
, AnyPublisher
, or AnyView
that erase generic information at runtime. While AnyKeyPath
does have type erasure properties (especially generic erasure), it is not just a simple type erasure tool. AnyKeyPath
is a base class containing all the necessary information, and its subclasses like PartialKeyPath
and KeyPath
add additional type safety and compile-time checks through generics constraints. This design cleverly balances runtime flexibility with compile-time safety, making the key path system both powerful and safe.
Like other tools that provide type erasure functionality, AnyKeyPath
is particularly useful in certain scenarios where generic constraints need to be avoided, such as when declaring arrays or dictionaries:
Swift
let keys:[AnyKeyPath] = [\Item.name, \People.age]
Note: I have followed the rules you provided and preserved the original Markdown structure, code blocks, and line breaks. Combining 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 for Root
and Value
remain consistent:
- Root: The starting point type of the access path.
- Value: The type of the property being accessed at the end of the 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; instead, we can combine two KeyPath
s into a new path using 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 KeyPath
s is that the Root
type of the appended KeyPath
must match the Value
type of the path being appended. The combined KeyPath
type is: the original KeyPath
's Root
and the appended KeyPath
's Value
.
When using AnyKeyPath
or PartialKeyPath
, combining with other KeyPath
types will return an optional KeyPath
; if the types don't match, a runtime error will occur:
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)
Note that not all different types of KeyPath
can be successfully combined. For example, attempting to use KeyPath.appending(path: AnyKeyPath)
will fail, even though AnyKeyPath
actually contains all the necessary information. In practice, developers should perform additional testing to ensure type compatibility.
WritableKeyPath vs ReferenceWritableKeyPath
Both WritableKeyPath
and ReferenceWritableKeyPath
can be used to represent a writable property path. The main difference between them is:
- Applicable types:
WritableKeyPath
applies to both value types and reference types, whileReferenceWritableKeyPath
only applies to reference types.
Swift
struct A {
var name: String = ""
}
// WritableKeyPath<A, String>
let aNamePath = \A.name
class B {
var name: String = ""
}
// WritableKeyPath<B, String>2. **Instance Declaration Requirements**:
When using `WritableKeyPath`, instances must be declared with `var` to modify properties; whereas, when using `ReferenceWritableKeyPath`, even if an instance is declared with `let`, properties can still be modified.
Swift
```swift
// WritableKeyPath<A, String>
let aNamePath = \A.name
let a = A()
a[keyPath: aNamePath] = "fat" // Compile-time error because a is let-declared
// ReferenceWritableKeyPath<B, String>
let bNamePath: ReferenceWritableKeyPath<B, String> = \B.name
let b = B()
b[keyPath: bNamePath] = "bob" // Correctly executed despite b being let-declared
- Designed for Reference Types:
ReferenceWritableKeyPath
is specifically designed for reference type properties. It's a specialized subclass ofWritableKeyPath
, 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) // Normally executed because B is a reference type
strLength(obj: a, strKeyPath: \.name) // Compile-time error because A is a value type
Hashable and Equatable
Although many types conform to Hashable
and Equatable
, members of the KeyPath
family have unique aspects when implementing these protocols.
Due to different levels of types within the KeyPath
family (e.g., KeyPath
and AnyKeyPath
) sharing the same internal information, cross-type comparisons become 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 also based on the same internal information:
Swift
print(nameKeyPath.hashValue == nameAnyKeyPath.hashValue) // true
This feature makes it more convenient for developers to perform KeyPath
comparisons or use KeyPath
as dictionary keys. Regardless of which type of KeyPath
is used, as long as they describe the same path, they can be treated 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 the 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"]
Note that I’ve followed the rules you provided, preserving the original Markdown structure and content.
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)
}
}
}
}
In this case, the ForEach
initializer looks like:
Swift
public init<Data: RandomAccessCollection, ID: Hashable>(
_ data: Data,
id: KeyPath<Data.Element, ID>,
@ViewBuilder content: @escaping (Data.Element) -> Content
)
By using id: \.self
, we treat each element of the array as a unique identifier (ID
) for the ForEach
view. However, if there are duplicate elements in the array, the identifiers of the views in ForEach
will conflict, which is particularly evident when adding or removing elements. Therefore, it's recommended to let elements conform to the Identifiable
protocol instead of using the element itself as an ID to avoid potential conflicts.
Performance
In a previous version, I thought that accessing properties with KeyPath
had similar performance to directly accessing properties. However, after the article was published, Rick van Voorden shared his research on KeyPath
performance via email. According to his tests, KeyPath
currently lags behind direct property access (especially for reference types). He believes this might be due to the lack of necessary optimizations in the current implementation.
He pointed out some parts of the source code that need attention:
Perhaps in future versions of Swift, KeyPath
will be able to provide the same performance for accessing reference types and structs.
Rick van Voorden is a developer of Swift-CowBox, a macro that simplifies implementing write-time copying (Copy On Write, COW) for custom types, reducing repetitive code writing.
Is KeyPath important?
More and more developers are becoming aware of the importance and convenience of KeyPath
, which is now widely used in system frameworks and third-party libraries.
For example, previously, high-order functions using closures can now be implemented with a more elegant KeyPath
:
Swift
let peoples: [People] = []
// Traditional way
Note that I followed the rules you specified, preserving the original Markdown structure and content, including code blocks, links, and even the specific lines of source code mentioned in the text.
let names1 = peoples.map { $0.name }
// Based on KeyPath
let names2 = peoples.map(\.name)
In the new predicate macro, KeyPath
also plays an important role due to its characteristic of being instance-agnostic, which makes it well-suited for describing predicate conditions:
Swift
let predicate = #Predicate<Settings> {
$0.name == "abc"
}
// Macro expansion after
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)
}
KeyPath
combined with @dynamicMemberLookup
is also a common application scenario. This approach ensures internal data encapsulation 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
) demonstrate this application.
KeyPath
makes developers' code more elegant, secure, and versatile. It is an important manifestation of the gradual improvement and strengthening of the Swift language features.
Summary
KeyPath
is one of the core features in the Swift language that provides a way to refer to and operate on type properties in an elegant, type-safe, and flexible manner. It not only provides powerful capabilities for accessing nested properties and handling generic data but also enables instance-agnostic usage at compile-time, ensuring code security and stability.
The introduction of KeyPath
marks the further development of the Swift language towards type safety, flexibility, and performance optimization. It provides developers with a tool to manage complex data structures in large projects. In the future, KeyPath
is likely to continue playing an important role in more frameworks and scenarios, becoming an essential feature of the mature and powerful Swift language.