Expand description
§irys — Compile-Time Trait Reflection for Rust
Automatically discover which traits a type implements at compile time, without per-type annotation.
use irys::*;
use std::fmt;
// 1. Define a capability (marker struct + what trait object it maps to)
struct DisplayCap;
impl Capability for DisplayCap {
type Handle = dyn fmt::Display;
}
// 2. Register it (one-time, blanket — covers ALL types that implement Display)
register_capability! {
slot = 0,
cap = DisplayCap,
trait_bound = fmt::Display,
}
// 3. Reflect any value — capabilities are detected automatically
let envelope = reflect!("hello world");
assert!(envelope.has::<DisplayCap>());
let display = envelope.get::<DisplayCap>().unwrap();
assert_eq!(format!("{}", display), "hello world");§Why irys?
Every existing reflection system in Rust requires per-type registration:
| System | Registration cost | Trait discovery? |
|---|---|---|
| irys | 1 per trait (blanket covers all types) | Yes, automatic |
bevy_reflect | 1 per type + 1 per type×trait | Partial (explicit #[reflect(Trait)]) |
typetag | 1 per type×trait | No (serde only) |
inventory/linkme | 1 per type | No |
std::any::Any | None | No (downcast only) |
With irys, you register a capability once and it automatically applies to every type that satisfies the trait bound — past, present, and future. No derives, no proc macros, no per-type boilerplate.
§Core Concepts
§Capabilities
A capability is a marker struct that maps to a trait object type. It answers the question “can this value do X?”:
struct SerializeCap;
impl Capability for SerializeCap {
type Handle = dyn erased_serde::Serialize;
}§Registries
A registry is a namespace for capabilities. It isolates your capability slots from other libraries so they can’t collide:
struct MyRegistry;
register_capability! {
registry = MyRegistry,
slot = 0, // slot 0 in YOUR registry — no conflict with anyone else's slot 0
cap = DebugCap,
trait_bound = fmt::Debug,
}When no registry is specified, DefaultRegistry is used.
§Slots
Each capability occupies a slot (a number) within a registry. The slot determines which compile-time probe fires during reflection. Two capabilities at the same slot in the same registry will produce a compile error — this is intentional collision detection.
You only probe the slots you use. 5 capabilities? Probe 5 slots. This means compile time scales with what YOU care about, not what exists in the ecosystem.
§Envelopes — The Ownership Model
irys follows the same ownership model as Vec<T> / &[T] / &mut [T]:
| Type | Created by | Access |
|---|---|---|
Envelope | reflect! | Full ownership: get, get_mut, into_data |
EnvelopeRef | reflect_ref! or Envelope::as_ref | Shared: get, data |
EnvelopeMut | reflect_mut! or Envelope::as_mut | Mutable: get, get_mut, data_mut |
// Owned — consumes the value
let envelope = reflect!(42i32);
// Shared borrow — value remains available
let value = 42i32;
let envelope_ref = reflect_ref!(&value);
assert_eq!(value, 42); // still usable
// Mutable borrow — can mutate through capabilities
let mut counter = Counter { count: 99 };
{
let mut env = reflect_mut!(&mut counter);
env.get_mut::<ResettableCap>().unwrap().reset();
}
assert_eq!(counter.count, 0); // mutated in placeConversions work like you’d expect:
envelope.as_ref()→EnvelopeRef(borrows the envelope’s map, zero-cost)envelope.as_mut()→EnvelopeMut(borrows the envelope’s map, zero-cost)envelope_mut.as_ref()→EnvelopeRef(downgrade to shared)
§Registries & Slot Ranges
By default, reflect! probes the DefaultRegistry for 256 slots. You can customize
which registries and how many slots to probe:
let envelope = reflect!(value, [
{ registry: CoreRegistry, slots: 0..5 },
{ registry: ObsRegistry, slots: 0..3 },
]);This gives you precise control over compile-time cost: only probe the slots you actually use. If you have 5 capabilities, probe 5 slots — not 256.
Registries probed later override earlier ones for the same capability (last-write-wins). This gives you natural override/specialization semantics without language-level specialization.
§The Reflectable Trait
For generic code that needs to work with reflected values, implement Reflectable.
The impl_reflectable! macro does this in one line:
struct MyEvent { data: String }
impl_reflectable!(MyEvent, [{ registry: MyRegistry, slots: 0..10 }]);
// Generic code can accept anything Reflectable
fn publish(event: impl Reflectable) {
let envelope = event.reflect();
// route based on capabilities...
}
// Also works with borrowed access
fn inspect(event: &impl Reflectable) {
let envelope_ref = event.reflect_ref();
// read-only capability access...
}This is analogous to how bevy_reflect uses #[derive(Reflect)] — but here it’s
a one-line macro invocation with no proc macros.
§Common Patterns
§Cloning an Envelope
Envelope can’t implement Clone directly (the inner value is type-erased). Instead,
register Clone as a capability, then use caps() +
from_raw() to reconstruct:
use std::any::Any;
trait DynClone: Send + Sync {
fn clone_boxed(&self) -> Box<dyn Any + Send + Sync>;
}
impl<T: Clone + Send + Sync + 'static> DynClone for T {
fn clone_boxed(&self) -> Box<dyn Any + Send + Sync> {
Box::new(self.clone())
}
}
struct CloneCap;
impl Capability for CloneCap {
type Handle = dyn DynClone;
}
register_capability! {
slot = 0,
cap = CloneCap,
trait_bound = DynClone,
}
// Clone an envelope:
let envelope = reflect!(MyData(42));
let cloned_data = envelope.get::<CloneCap>().unwrap().clone_boxed();
let cloned_envelope = Envelope::from_raw(cloned_data, envelope.caps().clone());The capability map clone is cheap (just Arc refcount bumps internally). If the map
doesn’t match the data type (e.g., after calling from_raw with wrong data),
get() safely returns None — no panics.
§Event Bus Routing
Route heterogeneous events based on discovered capabilities:
use std::sync::{mpsc, Arc};
fn router(rx: mpsc::Receiver<Arc<Envelope>>) {
while let Ok(envelope) = rx.recv() {
if let Some(prio) = envelope.get::<PriorityCap>() {
if prio.is_critical() {
escalate(&envelope);
}
}
if envelope.has::<SerializeCap>() {
persist(&envelope);
}
}
}§Composing with Other Reflection Libraries
irys can wrap other reflection systems as capabilities:
struct ReflectCap;
impl Capability for ReflectCap {
type Handle = dyn bevy_reflect::Reflect;
}
register_capability! {
slot = 0,
cap = ReflectCap,
trait_bound = bevy_reflect::Reflect,
}
// Now any type implementing Reflect is automatically detected
// irys handles discovery, bevy_reflect handles structural introspection§How It Works
irys uses a technique called autoref specialization combined with const generics to achieve compile-time trait detection on stable Rust. Here’s the mechanism:
-
register_capability!generates two impls for each capability:- An inherent-like impl on
Probe<T, Registry, N>with a trait bound (T: MyTrait) - A blanket trait impl on
&Probe<T, Registry, N>with no bound (the fallback)
- An inherent-like impl on
-
reflect!expands (viaseq!) into a loop calling.probe()for each slot. Rust’s method resolution prefers the inherent impl when the bound is satisfied, falling back to the trait impl (which is a no-op) when it’s not. -
The compiler resolves this statically — there’s no runtime branching. For capabilities a type doesn’t have, the optimizer eliminates the no-op entirely.
-
Registries are just type parameters on
Probe, giving each namespace its own set of inherent impls that can’t collide with other registries.
The result: zero runtime cost for undetected capabilities, and the entire detection happens at compile time.
§Limitations
-
Concrete types only at the
reflect!()call site: The autoref trick requires the compiler to see the concrete type. In generic functions whereTis abstract, use theReflectabletrait pattern (implementReflectableon concrete types, then bound generic code withT: Reflectable). This is the same constraint every Rust reflection library has — bevy_reflect requires#[derive(Reflect)]on concrete types too. -
Capabilities only propagate downward: A
reflect!()call can only detect capabilities whoseregister_capability!was visible at compile time — i.e., defined in the same crate or in a dependency. If crate A callsreflect!()and crate B (which depends on A) registers a new capability, crate A will NOT see it. This is a fundamental consequence of Rust’s compilation model: upstream crates are compiled before downstream crates exist.Workarounds: Have the upstream crate accept a pre-constructed
Envelope, afn() -> Envelope, or a type implementingReflectable. This pushes thereflect!()call to the downstream crate where all capabilities are visible. -
Slot management is manual: You pick slot numbers. The compiler catches collisions (same registry + same slot = compile error), but you manage the allocation. Use registries to isolate your slots from other libraries.
-
unsafeinternally: Fat pointer transport between type-erased closures requirestransmute_copy. This is sound (trait object fat pointer layout is guaranteed) but the code does containunsafeblocks internally. The public API is fully safe.
Macros§
- impl_
reflectable - Implement
Reflectablefor a type with minimal boilerplate. - reflect
- Reflect a value, automatically detecting which capabilities it supports.
- reflect_
mut - Reflect a mutable reference, detecting capabilities with mutable access.
- reflect_
ref - Reflect a shared reference, detecting capabilities without consuming the value.
- register_
capability - Register a capability so that
reflect!can detect it on any type satisfying the trait bound. - seq
Structs§
- Capability
Map - Storage for detected capabilities and their type-erased cast functions.
- Default
Registry - The default registry used when no registry is specified in
register_capability!orreflect!. - Envelope
- A reflected value with its discovered capabilities (owned).
- Envelope
Mut - A mutably borrowed reflected value with read + write capability access.
- Envelope
Ref - A borrowed reflected value with read-only capability access.
Traits§
- Capability
- A capability defines what trait object a type can be cast to.
- Reflectable
- Trait for types that know how to reflect themselves.