⚡ Velomorph
Declarative, type-safe struct transformation for Rust — with zero-copy patterns and optional background cleanup.
Why Velomorph?
Boundary layers (network packets, config blobs, legacy DTOs) often need the same mapping logic repeated across types: rename fields, unwrap options safely, borrow strings when possible, and validate before the rest of the system sees the data. Hand-written glue works, but it drifts, duplicates error handling, and hides intent.
Velomorph encodes those rules in one place with #[derive(Morph)] and attributes, so transformations stay explicit, consistent, and easy to review.
What you get
- Predictable mapping semantics — Strict
Option<T> → T, passthroughOption, and borrowedCowpaths are generated from types, not scatteredunwrapcalls. - Less boilerplate — Field renames (
from), conversions (with), defaults, skips, and post-checks (validate) without copy-pasting struct initializers. - Zero-copy where it fits —
Cow<'a, str>can borrow from the source when lifetimes allow. - Optional Janitor — Move expensive drops off your hot path when you enable the
janitorfeature and use the helper deliberately.
Key features
- Type-aware derive — The macro chooses strict vs passthrough vs borrowed strategies from your field types.
- Advanced controls — Enum morphing,
withtransforms, defaults, skips, and type-level validation hooks. - Flexible sources — Map from the default source type or set
#[morph(from = "...")]at type or field level. - Janitor (opt-in) — Tokio-backed channel to a background thread for deferred deallocation:
Janitor::new()/Default(unbounded, default) orJanitor::bounded(n)(capped queue; when full,offloaddrops on the caller—see below).
🏗 Project Structure
Velomorph is a Cargo workspace:
velomorph-lib— Runtime:TryMorph,MorphError, optionalJanitor.velomorph-derive— Procedural macro that implementsTryMorph.examples/full_showcase— Runnable examples for both janitor and non-janitor paths.
🚀 Quick Start
Add the following to your Cargo.toml:
Cargo.toml
[]
= "1.0"
Enable Janitor offloading explicitly when needed:
[]
= { = "1.0", = ["janitor"] }
Then create src/main.rs:
src/main.rs
use Cow;
use Uuid;
use ;
use Janitor;
// 1. Define your raw source data (e.g., from a network buffer).
// 2. Define your optimized domain model.
// Here we also *rename* the incoming fields using `#[morph(from = "...")]`.
async
Showcase Modes (Explicit Janitor Usage)
The full_showcase example demonstrates both execution paths clearly:
- Without janitor feature:
- With janitor feature enabled:
In janitor mode, the example explicitly offloads the heavy payload via Janitor::offload(...) before morphing.
🛠 How it Works
Background Deallocation (The Janitor Pattern)
In high-load systems, calling drop() on a large Vec or a complex tree can take several milliseconds as the OS reclaims memory. Velomorph provides a Janitor helper that moves those objects to a dedicated OS thread via a Tokio channel.
Janitor supports two modes (same TryMorph API; you still pass &Janitor):
| Constructor | Queue | Behavior |
|---|---|---|
Janitor::new() / Default |
Unbounded | offload never blocks for backpressure. If you enqueue faster than the worker drops, memory can grow without bound and may eventually OOM. Prefer for controlled workloads. |
Janitor::bounded(n) |
Bounded (capacity n > 0) |
At most n items wait in the channel. When full, offload drops the value on the caller thread (that call is not deferred), so the queue stays capped and this stays safe to call from async runtimes (no blocking send on the Tokio worker). |
This keeps your hot path from stalling on large drops when you offload deliberately, while letting you choose latency-first (unbounded) vs capped pending-deferred-work (bounded) semantics.
The Morph Macro Logic
The #[derive(Morph)] macro performs a deep analysis of your struct fields at compile time to generate the most efficient mapping possible:
| Target Type | Source Type | Strategy | Result |
|---|---|---|---|
T |
Option<T> |
Strict | Returns MorphError::MissingField if None. |
Option<T> |
Option<T> |
Passthrough | Moves the Option as-is. |
Cow<'a, str> |
&'a str |
Zero-Copy | Borrows the string (no heap allocation). |
You can also choose a custom source type instead of the default RawInput:
Advanced attributes:
with transform functions currently use the form fn(SourceType) -> Result<TargetType, E>.
Enum targets use same-name variant mapping by default, with per-variant overrides via #[morph(from = "...")].
List Mapping (Vec<T> -> Vec<U>)
You can morph whole vectors when each element implements TryMorph to the target type:
use TryMorph;
// Works without janitor feature:
// let mapped: Vec<Target> = source_vec.try_morph()?;
//
// Works with janitor feature:
// let mapped: Vec<Target> = source_vec.try_morph(&janitor)?;
This is implemented as TryMorph<Vec<U>> for Vec<T> where T: TryMorph<U>, and short-circuits on the first MorphError.
Memory Safety & Lifetimes
Velomorph is built on top of Rust's strict ownership rules. By using Cow<'a, str>, the compiler guarantees that the source buffer (e.g., your network packet) lives at least as long as your transformed InternalEvent. If the source buffer is dropped, the compiler will catch the error at build time.
When to use Velomorph vs hand-written code
Performance reality
Benchmarks in this repo show that hand-written mapping can be a few nanoseconds faster in tiny morph-only micro-cases. In practice, that difference is often acceptable (or irrelevant) because real bottlenecks are usually elsewhere: I/O, parsing, serialization, network waits, database calls, or large memory copies/drops.
Practical rule of thumb
Use Velomorph by default when you want faster delivery, safer boundaries, and consistent mapping behavior across many structs.
Use hand-written code selectively for tiny, stable, inner-loop hot paths where profiling proves that this exact mapping function is the bottleneck.
For the measured numbers and methodology, see the benchmark section below.
📊 Benchmarks: Performance Proof
These benchmarks are split into multiple groups to avoid misleading conclusions:
MorphOnly_NoPayloadClone: measures transform logic only (no 1MB payload clone in loop).PayloadCloneDrop_1MB: measures clone/drop-heavy end-to-end behavior separately.VecMorph_NoPayloadClone: measures vector-morphing overhead (1k elements) without the 1MB payload clone.
Latest Run (Apr 2, 2026)
These numbers are from a local benchmark run. Absolute timings can shift on production servers due to CPU/power settings, scheduler differences, and background contention, but the relative conclusions about "morph-only" vs "clone/drop-heavy" work still hold.
Command:
Results:
| Group | Benchmark | Time (range) |
|---|---|---|
| MorphOnly_NoPayloadClone | Velomorph | 21.778 ns - 23.450 ns |
| MorphOnly_NoPayloadClone | ManualBorrowed | 17.408 ns - 18.290 ns |
| PayloadCloneDrop_1MB | CloneRawInput | 18.061 us - 18.608 us |
| PayloadCloneDrop_1MB | ManualBorrowed_afterClone | 17.940 us - 18.409 us |
| PayloadCloneDrop_1MB | Velomorph_afterClone | 18.591 us - 19.322 us |
| VecMorph_NoPayloadClone | VelomorphVec_1k | 32.099 us - 33.489 us |
| VecMorph_NoPayloadClone | ManualVecBorrowed_1k | 20.770 us - 21.670 us |
Interpretation
- Morph-only cost remains nanosecond scale, so both variants stay highly efficient at pure field mapping.
- 1MB clone/drop dominates end-to-end timing (microseconds), which matches the memory movement/allocation pressure expected in this path.
- Vector morphing adds additional microsecond overhead (1k elements). In this run,
ManualVecBorrowed_1kis faster thanVelomorphVec_1k. - Do not compare ns and us rows directly (and avoid mixing vector vs clone/drop categories). They intentionally measure different workloads/layers.
- This run reports statistically significant improvements for all shown sub-benchmarks (
p < 0.05), with small outlier counts observed bycriterion.
Reproducing
🗺 1.0 API surface
Velomorph 1.0 commits to semver stability for the public API described in this README and on docs.rs. Highlights:
- 🧩 Modular Janitor: Optional background cleanup (
feature = "janitor"); unbounded (Janitor::new/Default) or bounded (Janitor::bounded). - 🏷 Flexible Sources: Type-level and field-level
frommapping. - 🛠 Custom Transforms: Field-level
withtransforms. - 🧱 Defaults & Skips: Field-level
default/default = "..."andskipcontrols. - 🏗 Validation Logic: Type-level post-transformation validation hooks.
- 🔄 Enum Support: Same-name variant mapping with explicit variant overrides.
- 📦 List mapping:
TryMorph<Vec<U>> for Vec<T>whenT: TryMorph<U>.
🤝 Contributing
Contributions are what make the open-source community such an amazing place to learn, inspire, and create. Any contributions you make are greatly appreciated.
- Fork the Project
- Create your Feature Branch (
git checkout -b feature/AmazingFeature) - Commit your Changes (
git commit -m 'Add some AmazingFeature') - Push to the Branch (
git push origin feature/AmazingFeature) - Open a Pull Request
📜 License
Licensed under either of:
Clear mappings. Safer boundaries. ⚡ Velomorph.