π KeyPaths in Rust
Key paths provide a safe, composable way to access and modify nested data in Rust. Inspired by KeyPath and Functional Lenses system, this feature rich crate lets you work with struct fields and enum variants as first-class values.
Starter Guide
Installation
Add to your Cargo.toml:
[]
= "2.0.2"
= "2.0.2"
Basic usage
use Arc;
use Kp;
Composing keypaths
Chain through nested structures with then():
let street_kp = address.then;
let street = street_kp.get; // Option<&String>
Partial and Any keypaths
Use #[derive(Pkp, Akp)] (requires Kp) to get type-erased keypath collections:
- PKp β
partial_kps()returnsVec<PKp<Self>>; value type erased, root known - AKp β
any_kps()returnsVec<AKp>; both root and value type-erased for heterogeneous collections
Filter by value_type_id() / root_type_id() and read with get_as(). For writes, dispatch to the typed Kp (e.g. Person::name()) based on TypeId.
See examples: pkp_akp_filter_typeid, pkp_akp_read_write_convert.
More examples
Supported containers
The #[derive(Kp)] macro (from key-paths-derive) generates keypath accessors for these wrapper types:
| Container | Access | Notes |
|---|---|---|
Option<T> |
field() |
Unwraps to inner type |
Box<T> |
field() |
Derefs to inner |
Rc<T>, Arc<T> |
field() |
Derefs; mut when unique ref |
Vec<T> |
field(), field_at(i) |
Container + index access |
HashMap<K,V>, BTreeMap<K,V> |
field_at(k) |
Key-based access |
HashSet<T>, BTreeSet<T> |
field() |
Container identity |
VecDeque<T>, LinkedList<T>, BinaryHeap<T> |
field(), field_at(i) |
Index where applicable |
Result<T,E> |
field() |
Unwraps Ok |
Cow<'_, T> |
field() |
as_ref / to_mut |
Option<Cow<'_, T>> |
field() |
Optional Cow unwrap |
std::sync::Mutex<T>, std::sync::RwLock<T> |
field() |
Container (use LockKp for lock-through) |
Arc<Mutex<T>>, Arc<RwLock<T>> |
field(), field_lock() |
Lock-through via LockKp |
tokio::sync::Mutex, tokio::sync::RwLock |
field_async() |
Async lock-through (tokio feature) |
parking_lot::Mutex, parking_lot::RwLock |
field(), field_lock() |
parking_lot feature |
Nested combinations (e.g. Option<Box<T>>, Option<Vec<T>>, Vec<Option<T>>) are supported.
Performance: Kp vs direct unwrap
Benchmark: nested Option chains and enum case paths (cargo bench --bench keypath_vs_unwrap).
| Scenario | Keypath | Direct unwrap | Overhead |
|---|---|---|---|
| Read 3-level Option | ~2.25 ns | ~387 ps | ~5.8x |
| Write 3-level Option | ~854 ps | ~381 ps | ~2.2x |
| Read 5-level Option | ~3.54 ns | ~383 ps | ~9.2x |
| Read 5-level with enum | ~3.52 ns | ~392 ps | ~9x |
| 100Γ reuse (3-level) | ~36.6 ns | ~36.7 ns | ~1x |
| 100Γ reuse (5-level) | ~52.3 ns | ~52.5 ns | ~1x |
Access overhead comes from closure indirection in the composed chain. Reusing a keypath (build once, use many times) matches direct unwrap; building the chain each time adds ~1β2 ns.
Would static keypaths help?
Yes. Static/const keypaths would:
- Remove creation cost entirely (no closure chain construction per use)
- Allow the compiler to inline the full traversal
- Likely close the gap to near-zero overhead vs manual unwrap
Currently, Kp::then() composes via closures that capture the previous step, so each access goes through a chain of function calls. A static keypath could flatten this to direct field offsets.
Performance: LockKp (Arc<Mutex>, Arc<RwLock>)
| Operation | Keypath | Direct Locks | Overhead |
|---|---|---|---|
| Read | ~241 ns | ~117 ns | ~2.1x |
| Write | ~239 ns | ~114 ns | ~2.1x |
The keypath approach builds the chain each iteration and traverses through LockKp.then().then().then_async().then(); direct locks use sync_mutex.lock() then tokio_mutex.lock().await. Hot-path functions are annotated with #[inline] for improved performance.
10-level deep Arc<RwLock> benchmarks (leaf: f64)
Benchmark: 10 levels of nested Arc<RwLock<Next>>, reading/writing leaf f64. Run with:
cargo bench --features parking_lot --bench ten_level_arc_rwlockcargo bench --bench ten_level_std_rwlockcargo bench --features tokio --bench ten_level_tokio_rwlock
Read (get leaf value):
| RwLock implementation | keypath_static | keypath_dynamic | direct_lock |
|---|---|---|---|
| parking_lot | ~39 ns | ~44 ns | ~40 ns |
| std::sync | ~102 ns | ~123 ns | ~50 ns |
| tokio::sync | ~1.75 Β΅s | ~1.78 Β΅s | ~274 ns |
Incr (write: leaf += 0.25):
| RwLock implementation | keypath_static | keypath_dynamic | direct_lock |
|---|---|---|---|
| parking_lot | ~34 ns | ~41 ns | ~39 ns |
| std::sync | ~46 ns | ~54 ns | ~46 ns |
| tokio::sync | ~1.79 Β΅s | ~1.78 Β΅s | ~278 ns |
Static keypath (chain built once, reused) matches or beats direct lock for sync RwLocks. For tokio, async keypath has higher overhead than direct .read().await/.write().await; direct lock is fastest.
π License
- Mozilla Public License 2.0