Skip to main content

Crate irys

Crate irys 

Source
Expand description

§irys — Compile-Time Trait Reflection for Rust

If it can be expressed as a trait bound, you can reflect over it.

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:

SystemRegistration costTrait discovery?
irys1 per trait (blanket covers all types)Yes, automatic
bevy_reflect1 per type + 1 per type×traitPartial (explicit #[reflect(Trait)])
typetag1 per type×traitNo (serde only)
inventory/linkme1 per typeNo
std::any::AnyNoneNo (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]:

TypeCreated byAccess
Envelopereflect!Full ownership: get, get_mut, into_data
EnvelopeRefreflect_ref! or Envelope::as_refShared: get, data
EnvelopeMutreflect_mut! or Envelope::as_mutMutable: 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 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:

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, { registries: [{ registry: MyRegistry, slots: 0..10 }] });

// Generic code can accept anything Reflectable (with any config)
fn publish<C>(event: impl Reflectable<C>) {
    let envelope = event.reflect();
    // route based on capabilities...
}

// Also works with borrowed access
fn inspect<C>(event: &impl Reflectable<C>) {
    let envelope_ref = event.reflect_ref();
    // read-only capability access...
}

§Orphan Rule Dodge

The C type parameter on Reflectable<C> lets downstream crates implement reflection for upstream types without violating Rust’s orphan rule:

// Upstream crate defines this type — you can't modify it
struct ThirdPartyEvent { id: u64 }

// Your crate defines a config marker (local type = orphan rule satisfied)
struct MyConfig;

// Now legal! MyConfig is local, so you can impl Reflectable<MyConfig> for anything
impl_reflectable!(ThirdPartyEvent, { config: MyConfig });

// Library functions generic over C accept any config
fn process<C>(event: impl Reflectable<C>) {
    let envelope = event.reflect();
}

This is analogous to how bevy_reflect uses #[derive(Reflect)] — but here it’s a one-line macro invocation with no proc macros.

§Generic Capabilities

The real power of irys: register a capability with a generic type parameter, and the compiler resolves it for every concrete instantiation:

struct StreamCap<I>(PhantomData<I>);
impl<I: 'static> Capability for StreamCap<I> {
    type Handle = dyn Stream<Item = I>;
}

// Register ONCE — works for ALL item types
register_capability! {
    slot: 0,
    cap: StreamCap<I>,
    trait_bound: Stream<Item = I>,
    generics: [I: 'static],
}

// Query with specific types
envelope.has::<StreamCap<String>>();    // does it stream strings?
envelope.has::<StreamCap<Event>>();     // does it stream events?

This works with any trait that has associated types: Iterator<Item=T>, Future<Output=T>, Stream<Item=T>, AsRef<T>, etc.

No other Rust reflection library can do this. The Rust compiler does the heavy lifting: it infers type parameters, catches ambiguities at compile time, and eliminates unmatched probes entirely.

§The register_capability! Macro

Fields can be provided in any order:

FieldRequiredDescription
slotYesSlot number within the registry
capYesThe capability marker type
trait_boundYesTrait bound(s) that types must satisfy
registryNoRegistry (defaults to DefaultRegistry)
genericsNoExtra generic params: [I: 'static, U: Clone]
whereNoAdditional where clause bounds

§Common Patterns

§Adapter Traits — Compositional Capabilities

The most powerful pattern in irys: define an adapter trait with a blanket impl that composes multiple constraints, register it once, and it’s automatically detected on any type satisfying the combination.

Example: “I don’t care WHAT this iterates — just that each item is serializable”:

// The adapter trait — erases the item type
trait SerializableIter {
    fn next_ser(&mut self) -> Option<Box<dyn erased_serde::Serialize>>;
}

// Blanket impl — any Iterator with Serialize items qualifies
impl<T: Iterator> SerializableIter for T
where T::Item: erased_serde::Serialize + 'static {
    fn next_ser(&mut self) -> Option<Box<dyn erased_serde::Serialize>> {
        self.next().map(|item| Box::new(item) as _)
    }
}

struct SerializableIterCap;
impl Capability for SerializableIterCap {
    type Handle = dyn SerializableIter;
}

register_capability! { slot: 0, cap: SerializableIterCap, trait_bound: SerializableIter }

// Now ANY iterator with serializable items is detected automatically:
let logs = vec![LogEntry, LogEntry].into_iter();
let metrics = vec![Metric, Metric].into_iter();

assert!(reflect!(logs).has::<SerializableIterCap>());
assert!(reflect!(metrics).has::<SerializableIterCap>());

This pattern completely skips the need to probe for every concrete item type. No other Rust reflection library supports this — it requires blanket detection combined with the compiler’s full trait resolution, which only irys provides.

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

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.

  • Ambiguous generic registrations: If a type implements Trait<A> AND Trait<B>, a generic registration for Trait<T> will fail with a compile error — the compiler can’t infer which T to use. Register concrete instances instead (Cap<A> at slot 0, Cap<B> at slot 1). This is the compiler protecting you from ambiguity.

  • 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.

Macros§

impl_reflectable
Implement Reflectable for 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§

CapabilityMap
Storage for detected capabilities and their type-erased cast functions.
DefaultConfig
The default config used when no config is specified in impl_reflectable!.
DefaultRegistry
The default registry used when no registry is specified in register_capability! or reflect!.
Envelope
A reflected value with its discovered capabilities (owned).
EnvelopeMut
A mutably borrowed reflected value with read + write capability access.
EnvelopeRef
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.