Home

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.