irys 0.3.0

Compile-time trait reflection for Rust
Documentation

irys

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

Compile-time trait reflection for Rust. Automatically discover which traits a type implements — without per-type annotation, without proc macros, on stable Rust.

crates.io docs.rs license

Quick Start

use irys::*;
use std::fmt;

// 1. Define a capability
struct DisplayCap;
impl Capability for DisplayCap {
    type Handle = dyn fmt::Display;
}

// 2. Register it — ONE time, covers ALL types that implement Display
register_capability! {
    slot: 0,
    cap: DisplayCap,
    trait_bound: fmt::Display,
}

// 3. Reflect any value
let envelope = reflect!("hello world");
assert!(envelope.has::<DisplayCap>());

let display = envelope.get::<DisplayCap>().unwrap();
assert_eq!(format!("{}", display), "hello world");

Why irys?

System Registration cost Trait discovery? Generic capabilities?
irys 1 per trait Yes, automatic Yes (with type params)
bevy_reflect 1 per type + 1 per type×trait Partial No
typetag 1 per type×trait No (serde only) No
inventory/linkme 1 per type No No
std::any::Any None No (downcast only) No

With irys, you register a capability once and it blanket-covers every type satisfying the trait bound. 50 types implementing Display? One registration. 500 types? Still one registration.

Features

  • Zero per-type boilerplate — blanket detection via trait bounds
  • Generic capabilitiesIterator<Item=T>, Future<Output=T>, Stream<Item=T> with full type inference
  • Stable Rust — no nightly, no proc macros, one dependency (seq-macro)
  • Registries — namespace isolation, no slot conflicts between libraries
  • Pay for what you use — probe only the slots you care about
  • Vec/slice ownership modelEnvelope, EnvelopeRef<'a>, EnvelopeMut<'a>
  • Override semantics — last-write-wins gives you specialization without specialization
  • Orphan rule dodgeReflectable<C> lets downstream crates impl reflection for upstream types
  • Graceful mismatches — mismatched capability maps return None, never panic
  • Order-independent fieldsregister_capability! fields in any order
  • Full compiler support — Rust's type inference resolves capabilities, catches ambiguities

Generic Capabilities

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

use std::marker::PhantomData;

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

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

// Query with specific types:
envelope.has::<StreamCap<String>>()    // does it stream strings?
envelope.has::<StreamCap<Event>>()     // does it stream events?
envelope.get_mut::<StreamCap<u8>>()    // get a &mut dyn Stream<Item = u8>

This works with any trait that has associated types or type parameters:

// Futures
register_capability! {
    slot: 1,
    cap: FutureCap<O>,
    trait_bound: Future<Output = O> + Unpin,
    generics: [O: 'static],
}

// Iterators
register_capability! {
    slot: 2,
    cap: IterCap<I>,
    trait_bound: Iterator<Item = I>,
    generics: [I: 'static],
}

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. All fields:

Field Required Description
slot Yes Slot number within the registry
cap Yes The capability marker type
trait_bound Yes Trait bound(s) that types must satisfy
registry No Registry (defaults to DefaultRegistry)
generics No Extra generic params: [I: 'static, U: Clone]
where No Additional where clause bounds: [<I as Trait>::Assoc: Debug]
// All of these are equivalent:
register_capability! { slot: 0, cap: DebugCap, trait_bound: fmt::Debug }
register_capability! { cap: DebugCap, slot: 0, trait_bound: fmt::Debug }
register_capability! { trait_bound: fmt::Debug, cap: DebugCap, slot: 0 }

Adapter Traits — Compositional Capabilities

The most powerful pattern: define an adapter trait that composes multiple constraints, register it once, and it's automatically detected on any type satisfying the combination.

"I don't care WHAT this iterates — just that each item is serializable":

// 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 — Vec<LogEntry>, Vec<Metric>, anything. No per-type registration.

Ownership Model

Like Vec<T> / &[T] / &mut [T]:

// Owned — consumes the value
let envelope = reflect!(value);

// Shared borrow — non-consuming, read-only
let envelope_ref = reflect_ref!(&value);
// value is still usable here

// Mutable borrow — non-consuming, mutable trait access
let mut envelope_mut = reflect_mut!(&mut value);
envelope_mut.get_mut::<ResettableCap>().unwrap().reset();
// value is mutated in place

Conversions:

  • envelope.as_ref()EnvelopeRef
  • envelope.as_mut()EnvelopeMut
  • envelope_mut.as_ref()EnvelopeRef (downgrade)

Registries

Namespaces that isolate your capability slots from other libraries:

struct MyRegistry;

register_capability! {
    registry: MyRegistry,
    slot: 0,
    cap: SerializeCap,
    trait_bound: erased_serde::Serialize + Send + Sync,
}

// Only probe what you care about
let envelope = reflect!(value, [
    { registry: MyRegistry, slots: 0..5 },
]);

Two libraries can both use slot 0 without conflict — they're in different registries.

The Reflectable Trait

For generic code, implement Reflectable to enable reflection without knowing the concrete type:

struct MyEvent { data: String }

// One-liner via helper macro:
impl_reflectable!(MyEvent);

// With custom registries:
impl_reflectable!(MyEvent, {
    registries: [
        { registry: CoreRegistry, slots: 0..5 },
        { registry: ObsRegistry, slots: 0..3 },
    ]
});

// Now generic code works:
fn publish<C>(event: impl Reflectable<C>) {
    let envelope = event.reflect();
    // ...
}

Orphan Rule Dodge

The C type parameter on Reflectable<C> lets downstream crates implement reflection for upstream types:

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

// Your crate defines a config marker
struct MyConfig;

// Legal! MyConfig is local, so orphan rule is satisfied
impl_reflectable!(ThirdPartyEvent, { config: MyConfig });

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

Generic Types

use std::marker::PhantomData;

struct Wrapper<T, M> { data: T, _marker: PhantomData<M> }

// Keep M generic, pin T to String
impl_reflectable!(Wrapper<String, M>, {
    generics: [M: Send + Sync + 'static],
});

// With where clauses
struct Container<T> { data: T }

impl_reflectable!(Container<T>, {
    generics: [T: Send + Sync + 'static],
    where: [T: Clone],
});

Cloning Envelopes

Register Clone as a capability, then use caps() + from_raw():

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: 5, cap: CloneCap, trait_bound: DynClone }

// Clone an envelope:
let cloned_data = envelope.get::<CloneCap>().unwrap().clone_boxed();
let cloned_envelope = Envelope::from_raw(cloned_data, envelope.caps().clone());

Composing with Other Libraries

irys wraps any ecosystem trait as a capability:

// bevy_reflect — structural introspection
register_capability! { slot: 0, cap: ReflectCap, trait_bound: bevy_reflect::Reflect }

// erased_serde — type-erased serialization
register_capability! { slot: 1, cap: SerializeCap, trait_bound: erased_serde::Serialize + Send + Sync }

// std::error::Error — error handling
register_capability! { slot: 2, cap: ErrorCap, trait_bound: std::error::Error + Send + Sync }

How It Works

irys uses autoref specialization + const generics on stable Rust:

  1. register_capability! generates two impls per capability:

    • An inherent impl on Probe<__ProbeTarget, Registry, N> with a trait bound (fires when __ProbeTarget: Trait)
    • A blanket trait impl on &Probe<__ProbeTarget, Registry, N> (fallback no-op)
  2. reflect! expands into a loop (via seq!) probing each slot. Rust's method resolution prefers the inherent impl when the bound is satisfied, otherwise the no-op trait fires.

  3. The compiler resolves this statically — no runtime branching. Undetected capabilities are eliminated entirely by the optimizer.

  4. Registries are type parameters on Probe, giving each namespace independent method resolution.

Limitations

  • Concrete types at reflect!() call site — the compiler must see the actual type. For generic contexts, use Reflectable.
  • Capabilities only propagate downwardreflect!() can only detect capabilities whose register_capability! was visible when the crate was compiled. Workaround: accept Envelope or impl Reflectable<C> to push the call downstream.
  • Ambiguous generic registrations — if a type implements Trait<A> AND Trait<B>, a generic registration for Trait<T> will fail with a compile error. Register concrete instances instead.
  • Manual slot numbers — you pick them, the compiler catches collisions. Registries prevent cross-library conflicts.
  • unsafe internally — fat pointer transport uses transmute_copy. Sound (guaranteed layout), but the internal code has unsafe blocks. The public API is fully safe.

Installation

[dependencies]
irys = "0.3"

License

MIT