WEP: Library-Defined Derivation over Reflect*
Principle
The compiler's only job is to expose a type's structure and its identity. Every
derivation — built-in Inspect / serde / Default, Jade's JsonSchema,
user-written ones — is a generic library impl, static and monomorphized. No
per-capability synthesizer, no macros, no dynamic reflection.
The two are exposed as one family in two layers, because they answer to
different rules: identity through the root trait Reflect, which every nameable
type satisfies; structure through a sealed sub-trait per kind, gated on every
member being visible. Naming a type reveals nothing about what is inside it, so
the gate belongs to the kinds alone.
Two channels serve a derivation: a payload pack (FieldTypes / CasePayloads)
binds the per-member type variables ..F / ..P and drives the value-free
[..F::method()] expansion; a member walk — a tuple of member handles — carries
each member's value and metadata together.
impl<T: ReflectStruct<FieldTypes = [..F]>, ..F: SomeTrait> SomeTrait for T {
fn method(&self) -> R {
for let f of ReflectStruct::<T>::members() { // value + metadata per field
// … f.name() … f.get(self) …
}
let parts = [..F::method_of()]; // value-free, per field type
}
}
Reflection traits
One sealed, compiler-synthesized root — Reflect, carrying a type's identity —
and one sub-trait per type kind under it, carrying that kind's structure. All
are pub: a derivation names its bound, and derivations are the point, so a
library outside core: must be able to write one. All are reached through the
trait-qualified form
(ReflectStruct::<T>::…, never T::…) so a type's own method namespace stays
the author's. A user impl is a compile error, and the traits are callable only
in monomorphized contexts (T a concrete type). Structure stays split by kind: a
type is exactly one kind, so blanket impls over different kinds are disjoint.
A kind bound carries the root with it — Super Traits expands a declared bound to the
transitive closure of its supertraits — so a derivation that binds
T: ReflectStruct names its subject without asking for anything more, while a
consumer that only names binds T: Reflect and admits every nameable type,
reflected kind or not.
Every kind that has members spells its member channel the same way: type Members plus fn members(). A kind that has payloads adds a payload pack
alongside it. The type's scalar facts and the value→member build direction round
out each trait. Every per-member fact — name, wire override, doc, is_unit /
has_default / is_secret, value access — lives on the member, so no kind
carries a parallel metadata list or value accessor.
A newtype is the one kind with no members: it has a base, and its value bridges
are the bidirectional From the compiler already generates for it (Conversion Traits §7), so ReflectNewtype carries
neither a member channel nor a bridge of its own. Its #[wire(name_policy)]
names the newtype itself, which is why the policy sits on the root: it states a
fact about the declaration rather than about anything under it.
Identity and structure part ways at a newtype. It names itself through the
root, and inherits its base's structure like every other impl (Newtype Semantics).
So Reflect::<P>::type_name() answers "P" while ReflectStruct::<P>::members()
on type P = Point walks Point's fields.
The two bounds therefore hold at different depths: ReflectNewtype at the
newtype itself, a structure kind only after peeling to the base. That is rank 1
of Trait Resolution, so a derivation
keyed on the newtype kind beats one keyed on its base's. Inspect is the one
that uses it. Its as Name tag is a blanket over this kind, instantiated once
per chain link, so type A = i32; type B = A renders 7 as A as B.
pub trait Reflect { // identity — every nameable type
fn type_name() -> String; // the declaration's name
fn type_info() -> TypeInfo; // + module + this instantiation's args
fn wire_name_policy() -> CaseStyle; // #[wire(name_policy)], casing not applied
}
pub trait ReflectStruct: Reflect { // struct
type FieldTypes; // payload pack [F_0, F_1, …]
type FieldSlots; // [Option<F_0>, …]
type Members; // [StructField<Self, F_0>, …]
fn members() -> Self::Members;
fn from_fields(fields: Self::FieldTypes) -> Self; // assemble from field values
fn default_slot(index: i32) -> Self::FieldSlots; // field `index`'s `f: T = expr`
fn empty_slots() -> Self::FieldSlots; // every slot `None`
}
pub trait ReflectVariant: Reflect { // variant
type CasePayloads; // payload pack [P_0, …]; unit cases are ()
type Members; // [VariantCase<Self, P_0>, …]
fn members() -> Self::Members;
fn discriminant(&self) -> i32;
}
pub trait ReflectEnum: Reflect { // enum
type Members; // [EnumCase<Self>, …]
fn members() -> Self::Members;
fn discriminant(&self) -> i32;
fn from_discriminant(disc: i32) -> Option<Self>;
}
pub trait ReflectFlags: Reflect { // flags
type Members; // [FlagsBit<Self>, …]
fn members() -> Self::Members;
fn bits(&self) -> u64; // u64-normalized regardless of width
fn from_bits(raw: u64) -> Option<Self>;
}
pub trait ReflectNewtype: Reflect { // newtype
type Base; // `type UserName = String` → String
}
ReflectTemplate is a sixth kind, synthesized per tagged template literal
rather than per declaration. Its subject is the anonymous type a template
denotes, its payload pack is the hole types, and its members are TemplateHole handles
carrying the literal text around each hole. It follows every rule here: sealed,
monomorphized-only, one member channel. See
Tagged Template Literals.
members() returns a tuple, walked by tuple for-of; a generic derivation binds
one pack in its header, and the elaborator resolves the walk to the known member
type so member methods stay callable. Which pack a derivation binds follows from
whether the kind has payloads. A struct or variant member carries a payload type
parameter, so its walk is a heterogeneous mapped pack ([..StructField<T, F>] /
[..VariantCase<T, P>]) derived from the payload pack — and binding
FieldTypes = [..F] / CasePayloads = [..P] is what lets a derivation constrain
the payload types (..F: SomeTrait). An enum case and a flag bit carry no
payload and nothing to constrain, so a derivation binds Members = [..M]
directly, which serves only to source the arity. Either way Members is the
single member channel; no kind carries a parallel metadata list. (A tuple carries
no runtime index, so a homogeneous walk finds a member by holds rather than by
discriminant index, matching the variant walk.)
A generic derivation over a member walk binds a type pack, and both instance and
static trait methods resolve through such a pack-bound blanket — a deserialize
entry (T::from_wire(…)) dispatches the same way a walk does. A variant is no
exception: Variant::Case owns that namespace, but a name that is not a case
falls through to the blanket.
A free function may carry the same bound (fn render<L: ReflectFlags<Members = [..M]>, ..M>(v: &L)) and is called without naming either parameter: the pack
projects from the subject's member channel, which the call site computes since
the synthesized impls are not registered until after elaboration. A derivation
itself stays an impl — only that is picked up by a bound.
A derivation's method may carry type parameters of its own — serde's
serialize<S: Serializer>. The instance keys on the receiver, the bound's pack,
and the method's own arguments together, so one blanket body serves every
(subject, format) pair. Inferring such a parameter from an argument needs the
receiver to be a bound type parameter (fn f<T: Serialize>(v: &T), the shape
every serde entry point takes); a concrete subject reaching the derivation
through the blanket needs the turbofish.
Every kind spells its build direction from_<channel>. from_discriminant /
from_bits return Option because an unknown raw input is a normal deserialize
error, not a bug; from_fields is total, since a field-value tuple is already
typed. discriminant / bits read the live tag off a value.
FieldSlots is the value-level companion of FieldTypes: the same pack under
Option. default_slot(i) fills slot i with field i's declared default
(f: T = expr) and leaves every other slot None. A default is a value of the
field's own type, so it belongs to the payload channel, not to a metadata list.
A default is evaluated only where it is read, as a struct literal evaluates only
the defaults it needs. So default_slot takes the index rather than returning
every default. A constant index folds the other slots away once the call is
inlined. Keeping defaults off the member handle keeps every other members()
walk from materializing them.
Members
Every reflected member is a handle implementing the sealed Member trait — the
shared attr-reading face, so wire-naming and doc logic is written once and
reused across kinds.
pub trait Member {
fn name(&self) -> String; // source name
fn wire_name_override(&self) -> Option<String>; // #[wire(name)], casing not applied
fn doc(&self) -> Option<String>; // /// doc comment
}
struct StructField<T, F> { … } // Member + index() has_default() is_secret() get(&self, v: &T) -> F
struct VariantCase<T, P> { … } // Member + discriminant() is_unit() holds(&v) extract(&v) -> P construct(P) -> T make() -> T
struct EnumCase<T> { … } // Member + discriminant() holds(&v) make() -> T
struct FlagsBit<T> { … } // Member + bit() is_set(&v) set() -> T
VariantCase::make() builds a unit case, which construct cannot serve: a
derivation walking the case members writes one body per case, and
construct(()) type-checks only where the payload is (). It traps on a case
that is not unit, like every other value bridge over a mismatched member.
Members are sealed to these four stdlib types and minted only by members()
(their fields are private), so a program cannot forge one. A #[secret] field reports
is_secret() and takes the value-opaque Secret<F> projection in FieldTypes
(see Struct Walkability).
The value bridges (get / extract / construct / make / set) lower to a
discriminant-keyed access, so a forged member can trap but never misread a payload;
after inlining they fold to the code a hand-written impl would emit.
Reflection stops at these four handles: they are themselves generic structs, and
a handle's own Members would mention StructField<Self, …>, growing Self
without bound. They are not reflectable, by the same seal that rejects a user
impl.
A member carries no validation. A value's permitted range is a fact about the
field's type, not about the field, so it is declared once on that type through
Jade's Constrained and read from there by a boundary and a schema alike (see
Jade). Reflection stays
what the compiler alone can answer: a type's structure.
Type identity
type_name() names the declaration, so every instantiation of a generic type
answers alike ("Pair", not "Pair<String>" — see Generic types). A consumer that keys per instantiation — a schema
library's $defs, a registry, a cache — needs the instance instead, which is a
distinct fact rather than a different spelling. TypeInfo carries it: the
declaration's name and module, plus the instantiation's type arguments, each
itself a TypeInfo.
TypeInfo is a sealed variant, one case per kind, and
Total Reflection owns its shape: the
name, module and type arguments this section describes are what a declaration's
case carries, and a type with no declaration to name — a reference, a function
type — carries its components instead. Sealed as everything here is: a program
reads a case and mints none, and TypeInfo is not itself reflectable.
Identity sits on the root rather than on each kind. A kind bound gates a name
behind Visibility, so a type whose fields are private
(TreeMap<String,i32>) could not be named by the library that must key it; and
it would force a derivation to widen its bound to a kind just to name its
subject, which a schema library's impl<T: Constrained + Reflect> JsonSchema for T cannot do — a constrained type may be a newtype or a struct wrapper, and
Reflect admits both.
type_name() sits beside type_info() on the root: the allocation-free
shorthand serde's begin_struct calls, answering the declaration name
type_info() carries.
The parts are the identity; canonical_name() renders them in the canonical
register of Symbol Notation (MODULE
always quoted), the notation the compiler's diagnostics and dumps read back.
Rendering runs one way, and no single rendering serves every consumer — a
$defs key comes back through a $ref's URI reference, where < and > must
be escaped — so a schema library builds its key from the parts. Type arguments
are joined without a space (Pair<String,i32>), matching the spelling the
compiler renders internally; a type with none is written bare.
What can be named
Every type. Reflect is total, and which case answers for each is
Total Reflection's table. A tuple is
anonymous but its family is not — the prelude's internal type [..T]; owns it
(Variadic Type Parameters §3),
so its identity is the family plus the elements as type arguments, rendered
core:prelude#[i32,String]. () is the unit type rather than that family at
arity zero, and answers for itself.
Cost and shape
type_args() hands back an owned List, not a view. A Slice<T> holds an
&Array<T>, so handing one out borrow-escapes the array it views, and the
array a constant tree would view is a static global no parameter names. The copy
a view would save is the one Constant Object Globalization already removes
for members(): the tree is a closed constant expression, so the call is hoisted
to a global and read from there.
TypeInfo compares structurally (Eq over module, name, and arguments) because
that is what a $defs key or a registry lookup needs, and it is Inspect-able
for diagnostics. Display is deliberately absent: symbol notation's other
register — the shorthand that drops the quotes where unambiguous — has no
consumer yet, and canonical_name() serves the machine one.
It carries no base type. A newtype's base is ReflectNewtype::Base and a
constrained type's is Constrained::Base; a third spelling on TypeInfo would
answer the same question twice. TypeInfo names a type; it does not open one.
Building a struct from a streaming format
A struct's build direction is from_fields, which takes the field values in
declaration order — but a self-describing format delivers them in wire order,
and a pack map ([..F::method(args)]) applies one expression per element with
no per-element context beyond a mutable cursor. Neither side can reorder alone,
so the derivation holds the values: a slot tuple [..Option<F>] that starts
empty, is written by the wire loop at the index the format reports, and is read
back in declaration order.
The blanket binds T: ReflectStruct<FieldTypes = [..F]> + FieldSchema and
..F: Deserialize, and the walk needs three things the language grew for it: a
variadic for-of that binds the element index, that index as a tuple subscript
so the wire loop can write one slot, and the comprehension that reads the slots
back into from_fields' argument (WEP 2026-03-14).
A slot still empty at the end reads default_slot(i), which is what makes a
declared default an optional field. A field that declares none becomes a
MissingField error naming it.
The wire protocol is unchanged — next_field over FieldSchema, one streaming
pass — so no format implementation moves. FieldSchema is the one piece the
compiler still synthesizes per struct: lookup matches a wire key's bytes
against the field names without allocating a substring, which no library
derivation can spell.
The alternative — a DeserializeStruct serving fields in declaration order —
moves the reordering into every format (JSON, CBOR, NSD, core:args), each
buffering the whole object before the first field can be read. Rejected: it
charges every format for what one derivation needs, and the single streaming
pass is what the protocol is for.
Visibility
pub lets a module name a bound. Whether a subject satisfies that bound is
decided by the subject's own members, below. The two are separate, and only the
second withholds anything.
The traits and the member handles — StructField, VariantCase, EnumCase,
FlagsBit and TemplateHole — are in core:prelude's re-export, so a
derivation names its bound, and a helper signature names a handle, with no
import. They are the reflection API, documented where the rest of the prelude
is. Like every prelude name they are reserved: a module declaring its own
EnumCase is an error, and generated code (wadopoet, Gale) avoids them.
A type satisfies a T: Reflect* bound only where every one of its members is
visible. A declaration carries a single synthesized impl, so members() is
fixed and cannot be shortened for a caller that sees less; admitting a type on
one public member would enumerate its private ones alongside it. The shape is
observable as a whole or not at all. A type declaring no member is genuinely
memberless and always satisfies.
This gates reflection written about a type, not the impls derived for it. A
derived impl is synthesized at the declaration, where every member is visible,
so ${v:?} and serde keep rendering a foreign type in full — that is the
declaring module's own choice of representation, the same as deriving it by
hand. What visibility withholds is a third party enumerating a shape its owner
did not expose.
This is what keeps an abstraction like TreeMap out of a downstream
T: ReflectStruct without naming it: its fields are private, so nothing outside
its module can enumerate them.
The gate is on the kind traits alone, never on the root. A type's name and module
are public the moment the type is, so TreeMap<String,i32> satisfies Reflect
downstream and answers for its identity like any other type,
while ReflectStruct still refuses to enumerate it.
Generic types
A generic type reflects through one generic impl over S<T, …>, not through a
per-instantiation impl. FieldTypes / CasePayloads / Members bind the
declaration's own parameters and each instantiation substitutes them:
struct Pair<T> { left: T, right: i32 }
// FieldTypes = [T, i32] → Pair<String>: FieldTypes = [String, i32]
The trait shape is unchanged, so no reflection API is generic-specific. The
alternative — synthesizing a concrete impl once Pair<String> exists — is
circular: a derivation needs Pair<String>: ReflectStruct while
monomorphizing, which is exactly when the instance appears.
type_name() is the declared name ("Pair", not "Pair<String>"), matching
the plain-struct case and Rust's {:?}. The instantiation shows in
Reflect::<T>::type_info() instead: one declaration name and module for every
Pair<…>, with type_args() substituted per instance, so a consumer keying
$defs separates Pair<String> from Pair<i32> (see Type identity).
Two rules bound what is reflectable:
- A member's own type never disqualifies its owner. An associated-type
projection looks like an exception — an iterator adapter's
pub f: fn mut(I::Item) -> Udoes not substitute the declaration's own parameters — but the synthesized impl carries the declaration's bounds, soI::Itemresolves at the instantiation like any other projection. - A type whose members are not all visible at the use site is not reflectable
there (see Visibility). This is what leaves
TreeMap's hand-writtenInspectas the only candidate: a generic instance's mangled name (TreeMap<String, i32>) does not match the impl written forTreeMap, so the derivation would otherwise take it.
Value bridges
A generic type's value bridges ($field_get$S$F, $case_extract$V$P,
$case_construct$V$P) are minted after monomorphization — the only synthesis
that is. Lowering names a bridge by the concrete subject and member mangles, and
members sharing a mangled member type share one index-dispatched bridge:
Pair<i32> merges left: T with right: i32, Pair<String> keeps them apart.
The grouping exists only per instantiation, and the two call sites are
indistinguishable, so a generic bridge could not be selected.
A generic struct becomes its own monomorphized declaration, so its bridges are
minted off the declaration list. A generic variant never does (WEP 2026-02-09),
so its instantiations are the GenericInstance types naming it, read off the
type table. Its tag read is minted per instantiation too ($variant_tag$V): no
shared declaration exists to host one, and the base variant type has no GC
layout, so the receiver must be the instance.
Wire naming
The reflection layer exposes only the authored facts — a member's rename
override (Member::wire_name_override) and the type's name_policy
(Reflect::wire_name_policy as a CaseStyle). A resolved wire name is policy, and
casing is serialization vocabulary, not type structure, so it lives in
core:serde; any schema library (Jade) calls the same helper, so wire names never
diverge.
core:serde::wire_name(member, policy) resolves it: the explicit override wins,
otherwise the policy's casing applies to the source name. It reads only compile-time
facts, so it folds to a literal at each use site.
CaseStyle is total (Identity when no name_policy is set). apply_case covers
the six styles (camelCase, snake_case, PascalCase, SCREAMING_SNAKE_CASE,
kebab-case, SCREAMING-KEBAB-CASE). core:args reuses the same policy for CLI
tags, so "wire name" is the boundary-facing name in general, not a serde-only
concept.
Known gaps
The reflection traits, the member handles, the wire-naming split, and the
streaming struct build are implemented — core:serde derives every struct,
variant, enum, and flags impl through them, and a newtype reaches those
derivations through the base whose structure it inherits. So is the identity
root: Reflect carries type_name() for every kind, a kind bound reaches it
through the supertrait, and T: Reflect is a bound of its own. No format trait
is derived per declaration any more: Inspect's as Name tag is a blanket over
the newtype kind. What remains is what a schema library reads and
nothing else yet does.
Member::doc(). The trait carriesname()andwire_name_override()only, sodescription/titlehave no source. The fact is not lost: a doc comment lives in theTriviaMapthatwado docreads, and nothing carries it as far as a synthesized member.TypeInfois unimplemented: the root answerstype_name()but not yettype_info(), so an instantiation cannot be told from its declaration. Nothing in the tree needs a new mechanism — it is a sealed value minted like a member, and it rides the root's resolution path — but the body is per-instantiation, so it followstype_name()'s synthesis, where the resolved subject already carries the facts a declaration's case needs (its base name, module, and type arguments). Making the root total lands with it: today only a synthesized kind carries it. Both are Total Reflection's to close.
