swift-keypaths-extensions
Extensions for Swift KeyPaths. Starting with 0.2.0, KeyPathMapper has been extracted into the separate swift-keypath-mapping package, while this package focuses on optionality, composition, and re-exporting mapping APIs through KeyPathsExtensions. If you need key paths for enums, take a look at pointfreeco/swift-case-paths
Table of contents
Motivation
Swift key paths are powerful, but their composability breaks down in two common scenarios:
- when values need to be derived while preserving identity (e.g. SwiftUI bindings),
- and when optionality prevents paths from being composed or written to.
This package provides focused utilities that address these limitations while staying within Swift’s type system.
The Problem
1. Derived bindings lose identity
In SwiftUI, it’s common to derive a value from state:
struct Example: View {
@State
private var value: Float = 0
var body: some View {
Slider(value: Binding(
get: { Double(value) },
set: { value = Float($0) }
))
}
}This works functionally, but it breaks SwiftUI’s diffing model.
Bindings created with Binding(get:set:) are opaque and not Hashable, which prevents SwiftUI from reliably detecting derived changes.
A common workaround is to define computed properties on types:
extension BinaryFloatingPoint {
var double: Double {
get { Double(self) }
set { self = .init(newValue) }
}
}Such extensions lead to one of the following trade-offs:
private extensionmakes such helpers non-reusablepublic extensioncauses namespace pollution for extended type
Swift has no built-in concept for expressing such transformations outside the type they operate on.
2. Optional key paths cannot be composed freely
Swift supports optional chaining in key paths:
let kp: KeyPath<Root, Int?> = \Root.optionalProperty?.valueHowever, once optionality is involved, many useful operations become unavailable.
For example, combining key paths manually is not possible:
let kp1: KeyPath<Root, Property?> = \Root.optionalProperty
let kp2: KeyPath<Property, Int> = \Property.value
// ❌ Not available in Swift
let combined = kp1.appending(path: kp2)Even though this assignment is valid at runtime:
root.optionalProperty?.value = 03. Optionality breaks writability
Optional chaining also prevents writable key paths from being formed:
// ❌ Cannot convert KeyPath<Root, Int?> to WritableKeyPath<Root, Int?>
let kp: WritableKeyPath<Root, Int?> = \Root.optionalProperty?.valueAs a result, APIs that rely on WritableKeyPath cannot be used, even when the underlying mutation is safe and well-defined.
There is no standard way to:
- lift a non-optional key path into an optional context,
- unwrap an optional key path with a default value,
- or restore writability across optional boundaries.
Usage
This product re-exports KeyPathMapping and also provides utilities for working with key paths as values, particularly around optionality and composition:
-
withOptionalRoot() -
appending(path:)forOptional<Value>paths -
unwrapped(with:aggressive:)forOptional<Value>paths
struct Root {
struct Property {
var intValue: Int = 0
}
var optionalProperty: Property?
init(_ value: Int?) {
self.optionalProperty = value.map(Property.init(intValue:))
}
}// available out-of-the-box, recommended way when available
let kp_expression: KeyPath<Root, Int?> = \Root.optionalProperty?.intValue// if you have 2 arbitrary paths
// and kp_1.Value.Type doesn't match kp_2.Value.Type exactly
// (Optionality causes mismatch in that case)
let kp_1: KeyPath<Root, Property?> = \Root.optionalProperty
let kp_2: KeyPath<Property, Int> = \Property.intValue
// `kp_1.appending(path: kp_2)` is not available out-of-the-box
let kp_combined: KeyPath<Root, Int?> = kp_1.appending(path: kp_2)
// unwrapping is not available out-of-the-box
let kp_unwrapped: KeyPath<Root, Int> = kp_combined.unwrapped(with: 0)
// ⚠️ Unwrapped paths should be combined for reference types with caution
// Swift internals only allow non-aggressive unwrapping for reference typesWarning
KeyPathsOptionalTests.ReferenceTypeInReferenceType.aggressivelyUnwrapped() contains a note, mentioning that aggressive unwrapping is not guaranteed for nested reference types, at least when such unwrapped paths are combined with some other ones
Note
Dynamic member lookup does not currently support sendable key paths and even breaks autocomplete.
KeyPathsExtensions also provide "_Sendable"-prefixed keyPath aliases and unsafeSendable() methods
Installation
Basic
You can add swift-keypaths-extensions to an Xcode project by adding it as a package dependency.
- From the File menu, select Swift Packages › Add Package Dependency…
- Enter
"https://github.com/capturecontext/swift-keypaths-extensions"into the package repository URL text field - Choose products you need to link to your project.
Recommended
If you use SwiftPM for your project structure, add swift-keypaths-extensions dependency to your package file:
.package(
url: "https://github.com/capturecontext/swift-keypaths-extensions.git",
.upToNextMajor(from: "0.2.0")
)Do not forget about target dependencies:
.product(
name: "KeyPathsExtensions",
package: "swift-keypaths-extensions"
)License
This library is released under the MIT license. See LICENSE for details.