Wado

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:

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.