Sitemap

KeyPath in Swift

12 min readMar 12, 2025

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, while KeyPath is more like "car's left front wheel" – a general and universal description based on a type. This design makes KeyPath 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 as Sendable, it is designed to be immutable and can safely be passed between threads. However, the thread safety of data accessed through KeyPath 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 the Hashable and Equatable 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, including KeyPath, WritableKeyPath, and ReferenceWritableKeyPath. Although these variants essentially describe property access paths, they are suited for different contexts.
  • Composability: KeyPath has strong compositional capabilities, allowing developers to concatenate multiple KeyPaths. 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 or Value 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 the Value type.
  • Read-only access is allowed, but write operations are not permitted.
  • Uses one generic (Root) to support partially specialized KeyPaths.

3. KeyPath<Root, Value>

  • Both specifies Root and Value types.
  • Read-only access is allowed, but write operations are not permitted.
  • Uses two generics to provide a specific mapping from Root to Value.

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-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 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 KeyPaths 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 KeyPaths 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:

  1. Applicable types:
  2. WritableKeyPath applies to both value types and reference types, while ReferenceWritableKeyPath 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
  1. Designed for Reference Types:
  2. ReferenceWritableKeyPath is specifically designed for reference type properties. It's a specialized 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) // 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:

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

--

--

No responses yet