🔑 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.
Installation
Add to your Cargo.toml:
[]
= "3.4.0"
= "3.3.0"
# Optional: trait-only contracts (pulled in by rust-key-paths 3.1+)
# key-paths-core = "2.0"
rust-key-paths does not depend on rust-elm — that relationship runs the other way. Add rust-elm only if you want the Elm/TCA store:
[]
= "0.5.0"
= "3.4.0"
= "3.3.0"
Latest releases
| Crate | Version | Notes |
|---|---|---|
key-paths-core |
2.1.0 | #![no_std] traits; FieldDiff + hash_value for per-field change signals |
rust-key-paths |
3.4.0 | Kp, locks, HOF; RefKpTrait on Kp |
key-paths-derive |
3.3.0 | #[derive(Kp)], #[derive(Cp)], #[derive(FieldDiff)] |
rust-elm |
0.5.0 | Zero-copy StateBinding, subscribe_changes, scoped field signals |
rust_identified_vec |
0.1.2 | Identified collections (used by rust-elm) |
rust_dependencies |
0.1.2 | Typed DI for effects (used by rust-elm) |
3.4.0 / 2.1.0 / 3.3.0 / 0.5.0
FieldDiffinkey-paths-core— per-field hashing for change signals without cloning whole state.#[derive(FieldDiff)]inkey-paths-derive3.3.0 — generates{Struct}Fieldpath enum +field_hashes.rust-elm0.5.0 —StateBinding/ keypath projection,subscribe_changes,ScopedChangeSubscriber; see binding.md.
3.3.1 / 2.0.4 / 0.3.0
RefKpTraitinkey-paths-core— HRTBfocus/focus_mutfor local borrows;rust-key-pathsKpimplements it viaget_ref/get_mut_ref.rust-elm0.3.0 —RuntimeConfig(bus capacity, Tokio workers, reducer thread name); scope/store useRefKpTraitdirectly (StateLensremoved);StoreTask::finish_with_timeout; pain/ISO 20022 validation example.key-paths-derive3.2.0 — unchanged; still compatible.
3.3.0 / 2.0.3 / 3.2.0
EnumKp/EnumValueKpType— casepaths (prisms) for enum variants: extract + embed.EnumKp::get_ref,EnumKp::then/chain— compose nested casepaths.#[derive(Cp)]on enums and structs inkey-paths-derive3.2.0.rust-elm0.2.0 — separate crate: WebSocket subs, runtime features, ecommerce example (depends on this crate).rust_identified_vec0.1.2,rust_dependencies0.1.2 — patch releases.
3.1.1 / 2.0.1 / 3.0.2 (documentation)
- README guides for adapting keypaths via
Readable/Writable(including#[derive(Kp)]). - Confirmed
key-paths-deriveis not broken bykey-paths-core2.x — upgraderust-key-pathsto 3.1.1 in your app.
3.1.0 / 2.0.0 (initial trait stack)
key-paths-core2.0 — trait-only rewrite (Readable,Writable,KpTrait::then, …).rust-key-paths3.1 — depends on core 2.x; re-exports traits onKp.
Generic APIs with Readable / Writable
You can accept any keypath (including derived ones) in generic code:
use Kp;
use Readable;
See key-paths-core/README.md and key-paths-derive/README.md for migration from key-paths-core 1.7 and derive compatibility details.
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>
Casepaths (enum prisms)
Keypaths focus struct fields. Casepaths focus enum variants and support both extraction (read the payload when the enum matches) and embedding (wrap a payload in the variant).
Types
| Type | Role |
|---|---|
EnumKp |
Generic casepath (extractor Kp + embedder) |
EnumKpType<'a, E, V> |
Common alias: reference-shaped extract, fn embedder. Single-payload variants. |
EnumValueKpType<'a, E, P> |
Multi-field variants: payload P is extracted by value (clone), typically a tuple. |
Derive (#[derive(Cp)])
The fastest path — no manual variant_of wiring:
use ;
let child = child_cp; // EnumKpType<'static, Action, ChildAction>
let embedded = child.embed;
assert_eq!;
let card = card_cp; // EnumValueKpType<'static, Action, (String, String)>
let payment = card.embed;
assert_eq!;
Full variant matrix: key-paths-derive/README.md — Casepaths (Cp).
Factory helpers
use ;
// Option / Result built-ins
let some_kp = ;
assert_eq!;
// Custom enum variant (same as #[derive(Cp)] for single-field variants)
let cash_kp = variant_of;
assert_eq!;
// Pair #[derive(Kp)] variant accessor with constructor
// Action::child().with_embed(Action::Child)
Kp vs casepath
#[derive(Kp)] on enum |
#[derive(Cp)] / EnumKp |
|
|---|---|---|
| Struct fields | ✅ field() lens |
— |
| Enum variant extract | ✅ variant() |
✅ via extractor |
| Enum variant embed | ❌ | ✅ embed(payload) |
| Typical use | Chaining .then() through enums |
Scoping actions, prisms, routing |
Four-level nested actions
Each level is a single-payload variant. Compose casepaths with .then() / .chain() — one chain for both extract and embed (mirroring Swift CasePaths append):
let to_widget = app_cp
.then
.chain;
to_widget.get_ref; // extract leaf
to_widget.embed; // embed leaf → RootAction
In rust-elm, each scope/reducer layer still uses one casepath per step (Action::child_cp()); four levels means four nested scopes. For ad-hoc routing without scopes, use the composed chain above. See examples/casepath.rs.
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.
Features
| Feature | Description |
|---|---|
parking_lot |
Use parking_lot::Mutex / RwLock instead of std::sync |
tokio |
Async lock support (tokio::sync::Mutex, RwLock) |
arcswap |
arc-swap (Arc<ArcSwap<T>>, Arc<ArcSwapOption<T>>) via SyncKp |
pin_project |
Enable #[pin] field support for pin-project compatibility |
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 |
Pin<T>, Pin<Box<T>> |
field(), field_inner() |
Container + inner (when T: Unpin) |
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 SyncKp for lock-through) |
Arc<Mutex<T>>, Arc<RwLock<T>> |
field(), field_kp() / field() as SyncKp |
Lock-through via SyncKp |
Arc<arcswap::ArcSwap<T>>, Arc<arcswap::ArcSwapOption<T>> |
field_kp() / field() as SyncKp |
arcswap feature; use arcswap dependency key (see below) |
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.
arcswap (optional): atomically swappable Arc
Enable arcswap on rust-key-paths and add the same dependency key in your crate so generated paths resolve:
[]
= { = "3.2.0", = ["arcswap"] }
= { = "arc-swap", = "1.9" }
When to use ArcSwap instead of RwLock<Arc<T>>: you reload or publish whole snapshots (store / swap / rcu) and many threads read the current snapshot most of the time. Reads use load() (default strategy: low-latency, lock-free snapshots) instead of contending on a reader–writer lock. Prefer a static or LazyLock holding an ArcSwap when a single global pointer is enough; wrap in Arc<ArcSwap<T>> when the swap container is created at runtime and shared across threads (the outer Arc is only for sharing the container; hot-path reads touch the inner atomic pointer, not the Arc refcount).
When not to: you need a true in-place &mut T through a lock for arbitrary mutation of T inside the guard. ArcSwap stores an Arc<T>; updates replace the pointer. Use store / rcu at the call site for writes.
Chaining: compose the full lock path on the first SyncKp with .then(…) / .then_sync(…) (see examples/box_keypath_arcswap.rs). Import ChainExt for Kp::then_sync. Nested then_sync from the crate root can infer a 'static root in some compositions; if you hit that, call an inner SyncKp from a & to the inner struct (same example).
pin_project #[pin] fields (optional feature)
When using pin-project, mark pinned fields with #[pin]. The derive generates:
#[pin] field type |
Access | Notes |
|---|---|---|
Plain (e.g. i32) |
field(), field_pinned() |
Pinned projection via this.project() |
Future |
field(), field_pinned(), field_await() |
Poll through Pin<&mut Self> |
Box<dyn Future<Output=T>> |
field(), field_pinned(), field_await() |
Same for boxed futures |
Enable with pin_project feature and add #[pin_project] to your struct:
Examples: pin_project_example, pin_project_fair_race (FairRaceFuture use case).
Performance: box_keypath benchmark
Benchmark file: benches/box_keypath_bench.rs
Run with:
Read path (scsf -> sosf -> omse -> B -> dsf)
| Variant | Time (approx) |
|---|---|
| keypath | 996.46-997.18 ps |
| unwrap | 944.10-946.59 ps |
| as_ref().map | 996.31-997.39 ps |
? operator |
996.33-997.24 ps |
Write path (scsf -> sosf -> omse -> B -> dsf)
| Variant | Time (approx) |
|---|---|
| keypath | 147.44-149.09 ns |
| unwrap | 143.13-145.02 ns |
| as_ref().map | 141.04-142.65 ns |
? operator |
141.41-150.25 ns |
These numbers are from Criterion's reported confidence ranges on this machine. In this benchmark, keypaths are very close to direct traversal for reads and only slightly slower for writes.
Keypath size
From examples/box_keypath.rs, the composed keypath prints:
size of kp = 0
So this composed kp is zero-sized (no captured runtime state).
Performance: arcswap_keypath benchmark
Benchmark file: benches/arcswap_keypath_bench.rs (requires --features arcswap).
Read path (scsf → ArcSwap load → omse → B → dsf)
Compared to load_full() plus a manual match on the loaded OneMoreStruct (which clones the inner Arc on every read), the composed keypath uses load() under the hood and stays on the snapshot for the rest of the chain.
| Variant | Time (approx, --quick run on one machine) |
|---|---|
keypath_then_sync |
37.8–38.7 ns |
load_full_manual |
115–119 ns |
Your numbers will vary by CPU and optimization level; treat this as a sanity check that keypath traversal stays in the same ballpark as a small manual load, while load_full + clone is heavier by design when you need an owned Arc.
Skills guide (AI assistants & tooling)
Usage-focused patterns—versions/features, #[derive(Kp)], chaining, sync/async locks, deep nesting, migrating from Option / Java-style getter chains—live in Skills.md at the repository root.
Paths to reference: Skills.md (repo root), or ./Skills.md from this README’s directory. Point Cursor Rules, Agent Skills, or similar project instructions at that file so assistants can resolve the path and include it in context.
📜 License
- Mozilla Public License 2.0