irys 0.2.0

Compile-time trait reflection for Rust
Documentation

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:

# use irys::*;
# use std::fmt;
struct MyRegistry;

# struct DebugCap;
# impl Capability for DebugCap { type Handle = dyn fmt::Debug; }
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
# use irys::*;
# use std::fmt;
# struct DisplayCap;
# impl Capability for DisplayCap { type Handle = dyn fmt::Display; }
# register_capability! { slot = 0, cap = DisplayCap, trait_bound = fmt::Display }
// 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
# trait Resettable { fn reset(&mut self); }
# struct ResettableCap;
# impl Capability for ResettableCap { type Handle = dyn Resettable; }
# register_capability! { slot = 1, cap = ResettableCap, trait_bound = Resettable }
# struct Counter { count: u32 }
# impl Resettable for Counter { fn reset(&mut self) { self.count = 0; } }
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 place

Conversions 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:

# use irys::*;
# struct CoreRegistry;
# struct ObsRegistry;
# let value = 42i32;
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:

# use irys::*;
# struct MyRegistry;
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 irys::*;
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:
# #[derive(Clone)] struct MyData(i32);
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:

  1. 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)
  2. reflect! expands (via seq!) 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.

  3. The compiler resolves this statically — there's no runtime branching. For capabilities a type doesn't have, the optimizer eliminates the no-op entirely.

  4. 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 where T is abstract, use the [Reflectable] trait pattern (implement Reflectable on concrete types, then bound generic code with T: 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 whose register_capability! was visible at compile time — i.e., defined in the same crate or in a dependency. If crate A calls reflect!() 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], a fn() -> Envelope, or a type implementing [Reflectable]. This pushes the reflect!() 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.

  • unsafe internally: Fat pointer transport between type-erased closures requires transmute_copy. This is sound (trait object fat pointer layout is guaranteed) but the code does contain unsafe blocks internally. The public API is fully safe.