AI authored to showcase ironpad capabilities.
Ask a room of Rustaceans whether the language has runtime reflection and you will get a confident no. It is received wisdom: no Type.getFields(), no walking an arbitrary value's structure, no serialization without a derive macro generating the code ahead of time. Rust trades that away for zero-cost monomorphization, and everyone moves on.
As of mid-2026, the received wisdom is wrong twice over.
The first crack is in the compiler itself: the reflection MVP has landed. TypeId::info() now hands you a Type with a kind (tuple, struct, and a new kind supported roughly every week), and reflection-plus-comptime is a named 2026 project goal with the lang team actively on it.
The second crack you can run right now, in this notebook, on stable Rust: facet, by Amos (fasterthanlime). One #[derive(Facet)] and a value knows its own shape. Let me show you.
Everything below actually compiles to WebAssembly and runs in your browser. The Config and Telemetry types live in this notebook's shared source; the cells import facet and do the rest.
That is already a little uncanny. Config has no Debug, no Display, and no serde, yet facet-pretty rendered it in full. It did that by reading the shape the derive recorded, then walking the value against it.
Printing is the friendly demo. The shape itself is a first-class value you can hold and inspect. Config::SHAPE is a &'static Shape: a compile-time map of the type's fields, their names, and their types. You do not even need an instance to read it.
Before going deeper, put this on the map, because "reflection" gets used for two very different mechanisms, and the difference is the entire story.
serde's model is monomorphization. #[derive(Serialize)] runs at compile time, reads your struct definition, and writes out a bespoke Serialize impl: one function that mentions service, port, replicas, and debug by name, specialized to Config and nothing else. Condensed, the emitted code looks like this:
// what #[derive(Serialize)] generates for Config (condensed)
impl Serialize for Config {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
let mut st = s.serialize_struct("Config", 4)?;
st.serialize_field("service", &self.service)?;
st.serialize_field("port", &self.port)?;
st.serialize_field("replicas", &self.replicas)?;
st.serialize_field("debug", &self.debug)?;
st.end()
}
}
It is fast, and it is gone. The knowledge that Config has four fields gets burned into the instruction stream; at runtime there is no artifact left to ask "what fields does this thing have?". And every consumer repeats the work: Debug derives one field-walker, serde derives another, your ORM a third. Each pays compile time and binary size for every (type, tool) pair.
facet's model is reification. The derive does not emit walking code at all; it emits data: one &'static Shape per type, baked into the binary's read-only section. The structure of Config becomes a value with an address. Every tool (the pretty-printer above, the inspector and serializer below, a diff, a schema generator) is then a single generic function that reads that value. serde pays per type times per format; facet pays once per type, plus one walker per tool, shared across everything.
The table you just rendered was the reified shape. Time to see what else lives in it.
ShapeShape is the core type of the whole crate. The fields that do the work:
ty: what the type is declared as. For Config that is Type::User(UserType::Struct(...)), and the struct payload carries a fields slice with each field's name, shape, and byte offset, the offsets taken straight from core::mem::offset_of.def: how a consumer operates on a value: scalar, list, map, option, and friends. More on this split in a moment.layout: size and alignment, enough to allocate (though not initialize) one of these without ever naming the type.vtable: erased function pointers for display, debug, clone, hash, and equality, so generic code can invoke the type's own behavior without knowing the type.type_identifier, module_path, and even doc: the identifier, where it lives, and the doc comments from your source, preserved into the runtime artifact.That last one deserves a double-take. Your /// comments survive compilation and are readable by a generic function at runtime. Nothing in serde's world can do that; the comments never make it past the parser. Read a few of these fields out of Config's shape:
ty versus defThe split between ty and def confuses people for about five minutes and then becomes obvious. ty follows the Rust Reference taxonomy: primitive, tuple, struct, enum. def is operational: it tells a consumer which protocol a value speaks. A Vec<u32> and a SmallVec<u32> are different types with different ty, but both report Def::List, so a serializer handles them with one code path: ask for the length, iterate the items.
Structs report Def::Undefined because their structure lives entirely in ty; scalars like String and u16 report Def::Scalar and hang their behavior on the vtable. One cell makes the taxonomy concrete:
Everything above hangs off one question: what does #[derive(Facet)] expand to? Not code that walks fields. One static. Condensed, with the real field names and the elisions marked:
// what #[derive(Facet)] emits for Config (condensed)
unsafe impl<'a> Facet<'a> for Config {
const SHAPE: &'static Shape = &Shape {
type_identifier: "Config",
layout: ShapeLayout::Sized(Layout::new::<Config>()),
ty: Type::User(UserType::Struct(StructType {
fields: &[
Field { name: "service", shape: /* lazy ref to String's SHAPE */, offset: offset_of!(Config, service), /* .. */ },
Field { name: "port", shape: /* lazy ref to u16's SHAPE */, offset: offset_of!(Config, port), /* .. */ },
// replicas, debug: same pattern
],
/* .. */
})),
def: Def::Undefined,
vtable: /* fn pointers: debug, clone, hash, partial_eq, .. */,
/* .. */
};
}
Three properties fall out of this being data rather than code.
It is write-once. One static per type, shared by every consumer. Adding a fifth tool to your project adds zero per-type generated code; the tool is just another reader.
It is cheap to produce. facet's derive parses your struct with unsynn instead of syn, a deliberate choice: syn is the single heaviest build dependency in most Rust projects, and facet refuses to pay for a full Rust parser just to read a field list.
It composes. Each Field points at its own type's shape through a lazy reference (a function pointer under the hood, which is what makes recursive types work), so a generic walker can descend arbitrarily deep with no per-type help.
Field names, types, offsets, defs: all pulled out of thin air, no instance required. Now the reveal.
Because the shape is generic machinery, the code that reads it does not care which type it is looking at. Wrap the walk in one function bounded by T: Facet, add a Peek to read live field values, and you have an inspector that works on every #[derive(Facet)] type in the program. Same five lines, any type. That is the sentence that is supposed to be impossible in Rust.
Serialization is reflection you happen to consume. When you #[derive(Serialize)], serde's macro reads your type at compile time and writes out, by hand, the exact field-walking code we just did generically. Every type, every crate, every build.
So let me write a serializer the other way around: not code generated per type, but one generic function that walks any shape at runtime and emits JSON. No serde, no facet-json, about fifteen lines.
The inspector and the serializer were both instances of one pattern: a tool that used to require its own derive macro collapses into a single generic function. The next two instances are where this stops being a party trick and starts paying rent.
First: struct diff. Every ops team has a version of this problem: one config deployed, one proposed, and the question "what actually changed?". With serde you would serialize both to JSON and diff the text, dragging formatting noise and float-representation quirks along for the ride. With a reified shape you compare fields, using each field type's own equality (the vtable again), and report exactly the rows that differ:
Second: schema extraction. The diff needed two values; a schema needs zero. T::SHAPE is a const, so a generic function can describe a type it has never seen an instance of: field names, their types, which fields are required. That is exactly the artifact you want for validating config files before deserializing them, for generating API documentation in CI, or for feeding an editor's autocomplete. And because Shape carries doc, a fuller version of this generator would copy your /// comments into the schema's description fields for free:
Three ways to know a type in Rust today, and none of them dominates. The costs below are directional rather than precise (they vary hard with your type count and format count), but the shape of the trade-off is stable:
| serde | facet | bevy_reflect | |
|---|---|---|---|
| Mechanism | monomorphized code per (type, format) | one static Shape per type, generic walkers | dyn Reflect trait objects + runtime TypeRegistry |
| Runtime cost | lowest: direct calls, aggressively inlined | indirect: a vtable call per field visited | indirect, plus registry lookups |
| Compile time / binary size | grows with types × formats; syn-based derive | one static per type; light unsynn derive | heaviest: derive + registry + per-trait reflected impls |
| Inspect without an instance | no | yes: T::SHAPE is a const | partially: requires prior registration |
| Mutate through reflection | no | yes, via facet_reflect's write path | yes, its home turf: set fields by string path at runtime |
| Doc comments at runtime | no | yes | no |
| Maturity | 1.0, a decade in production | 0.x, moving fast | stable inside bevy's release cadence |
What each can do that the others cannot. serde: the fastest path from bytes to struct, full stop, plus a format ecosystem (dozens of production-grade crates) nobody else is close to matching. facet: hand you a type's structure as a plain const readable with zero setup, doc comments included; it is the only one of the three where "no instance, no registry, no allocation" all hold at once. bevy_reflect: mutation-first dynamic workflows, patching a component's field from a script or an editor while the program runs, with runtime type registration to match.
And the costs. serde re-derives the world once per tool. facet is 0.x, its API moves, and its serializers measurably trail serde's on hot deserialization paths today. bevy_reflect is heavy enough that people outside game engines rarely reach for it. Pick by which axis your problem actually sits on; there is no free lunch in this table.
Fifteen lines replaced the machinery a proc-macro emits for every struct you own.
Today every crate pays a derive-macro tax. serde, Debug, bevy's Component, facet itself: each ships a proc-macro that parses your type and generates trait impls, and you pay for it in compile time, in binary size, and in the papercut of remembering to add the derive in the first place. facet shrinks the tax to a single derive that many tools share, which is already a real win. But it is still a derive.
A compiler-provided reflection substrate removes the derive from the equation entirely. The 2026 reflection-and-comptime goal is explicit about the target: let tools introspect arbitrary types with no #[derive] and no trait bound, so that (one concrete example) Bevy could accept any type as a component instead of demanding #[derive(Component)] on it. dtolnay's reflect proof-of-concept has poked at the same space from the proc-macro side for years. The through-line is consistent: reflection wants to be a language capability, not a library obligation.
facet is deliberately positioned as the ecosystem consumer of that eventual API. Amos has been clear about the plan: when the compiler can hand out shapes directly, facet drops its own derive and keeps its ergonomics. The library is a placeholder for a compiler feature that is still being built, and you can ship it on stable today.
Two efforts, one direction, as of mid-2026.
TypeId::info(), a Type and kind surface, a new type kind landing roughly weekly, carried as a 2026 project goal with Josh Triplett and the lang team engaged. This is early. The API will move.facet, driven by Amos, is the thing you can use now: a no_std core, a light unsynn-based derive (no syn), and consumers that need no feature gate. It is 0.x, it moves fast, and it already does pretty-printing, reflection, and serialization off one derive.The received wisdom that "Rust has no reflection" was true for a long time. It is now the kind of thing you can disprove in a browser tab. If you want to follow the thread, start with the 2026 goal page, Amos's facet introduction, and whatever the latest This Week in Rust says, because on this topic that last caveat is load-bearing.