irys — Compile-Time Trait Reflection for Rust
Automatically discover which traits a type implements at compile time, without per-type annotation.
use *;
use fmt;
// 1. Define a capability (marker struct + what trait object it maps to)
;
// 2. Register it (one-time, blanket — covers ALL types that implement Display)
register_capability!
// 3. Reflect any value — capabilities are detected automatically
let envelope = reflect!;
assert!;
let display = envelope..unwrap;
assert_eq!;
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?":
;
Registries
A registry is a namespace for capabilities. It isolates your capability slots from other libraries so they can't collide:
# use *;
# use fmt;
;
# ;
#
register_capability!
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 *;
# use fmt;
# ;
#
# register_capability!
// Owned — consumes the value
let envelope = reflect!;
// Shared borrow — value remains available
let value = 42i32;
let envelope_ref = reflect_ref!;
assert_eq!; // still usable
// Mutable borrow — can mutate through capabilities
#
# ;
#
# register_capability!
#
#
let mut counter = Counter ;
assert_eq!; // 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 *;
# ;
# ;
# let value = 42i32;
let envelope = reflect!;
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 *;
# ;
impl_reflectable!;
// Generic code can accept anything Reflectable
// Also works with borrowed 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 *;
use Any;
;
register_capability!
// Clone an envelope:
# ;
let envelope = reflect!;
let cloned_data = envelope..unwrap.clone_boxed;
let cloned_envelope = from_raw;
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 ;
Composing with Other Reflection Libraries
irys can wrap other reflection systems as capabilities:
;
register_capability!
// 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 the [Reflectable] trait 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 implementing [Reflectable]. 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.