What a KeyPath costs
\User.name looks like syntax — a compile-time thing, like a function reference. It is a heap-allocated
object with a class hierarchy, and knowing that explains both when key paths are free and when they
are not.
The hierarchy
AnyKeyPath
└── PartialKeyPath<Root>
└── KeyPath<Root, Value>
└── WritableKeyPath<Root, Value>
└── ReferenceWritableKeyPath<Root, Value>
Each level adds a capability. KeyPath reads. WritableKeyPath writes to a var on a value type.
ReferenceWritableKeyPath writes through a let reference to a class — which is why
assign(to:on:) in Combine takes that specific type.
struct User { var name: String }
final class Session { var token: String? }
let read: KeyPath<User, String> = \.name
let write: WritableKeyPath<User, String> = \.name
let refWrite: ReferenceWritableKeyPath<Session, String?> = \.token
When they are free
Most of the time. The compiler is good at this.
users.map(\.name)
users.sorted(using: KeyPathComparator(\.age))
users.filter { $0.isActive }
map(\.name) with a literal key path is optimised into a direct property access in release builds.
There is no allocation and no indirection — the key path never really exists at runtime.
This is why the idiom is fine everywhere and I would not think twice about map(\.name) in a loop.
When they are not
The optimisation depends on the key path being a literal, known at the call site. Break either condition and you get the object.
Stored in a property or an array:
struct Column {
let title: String
let value: KeyPath<User, String> // a real object, allocated
}
let columns = [
Column(title: "Name", value: \.name),
Column(title: "Email", value: \.email),
]
Now each key path is a heap object, and reading through one is a dynamic lookup — it walks a buffer of offsets rather than compiling to a fixed offset.
Type-erased to AnyKeyPath: loses all type information and any chance of specialisation.
Hashed or compared: AnyKeyPath is Hashable, which is convenient for a dictionary keyed by
property, and hashing walks the key path’s components.
Tip
The rule that covers it: a key path literal passed directly to a function is free; a key path stored in a variable is an object. If you are building a table of columns, a form definition, or a mapping configuration, you are in the second case — and that is fine, because those are constructed once and read at UI speed.
Where the object form is worth it
The dynamic case is not something to avoid — it enables designs that are otherwise impossible.
Generic, type-safe configuration:
struct Column<Root> {
let title: String
let value: (Root) -> String
}
extension Column {
init<V: CustomStringConvertible>(_ title: String, _ keyPath: KeyPath<Root, V>) {
self.title = title
self.value = { String(describing: $0[keyPath: keyPath]) }
}
}
let columns = [
Column("Name", \User.name),
Column("Age", \User.age),
]
Type-safe, refactor-safe, and impossible with strings. Renaming name is a compile error rather than
a runtime surprise.
@dynamicMemberLookup for wrapper types:
@dynamicMemberLookup
struct Validated<Value> {
let value: Value
subscript<T>(dynamicMember keyPath: KeyPath<Value, T>) -> T {
value[keyPath: keyPath]
}
}
let user = Validated(value: User(name: "Tuan", age: 30))
user.name // reads through without unwrapping
This is how @Bindable in SwiftUI produces $model.property — dynamic member lookup with key paths.
The measurement
For anything doing real work, this is noise. Reading a stored key path is roughly a few nanoseconds more than a direct property access — a buffer walk rather than a fixed offset.
That matters in a loop over a million elements. It does not matter in a table view, a form, a sort comparator, or anywhere that touches the screen or the network.
The only case I have measured as significant: a sort over a large array using a stored
KeyPathComparator in a tight loop. Converting to a closure comparator was measurably faster, and it
was also less readable, and I kept the key path version.
What to take from it
Use key path literals freely — map(\.name), sorted(using:), assign(to:on:). They are free and
they read better than closures.
Store them when the design calls for it — column definitions, generic wrappers, dynamic member lookup. The object cost is real and it is paid at configuration time, not in a loop.
Only reach for a closure over a key path if a profiler has told you to, which so far it has told me once.