Programming Languages · Metaprogramming
Derive Macros and Structural Recursion Over Type Definitions
How a derive macro walks a struct or enum field by field to generate implementations, what generic parameters and lifetimes force on the generated code, and why bound inference goes wrong.
A derive macro is the narrowest useful form of procedural macro: it receives a struct or enum declaration and returns an implementation of one trait for that declaration. The pattern is structural recursion — visit the type’s fields, generate one piece of code per field, and join the pieces — and almost all of its difficulty comes from the three things a naive recursion ignores: generics, lifetimes, and the fact that a trait implementation must type-check for every instantiation of the type, not just the one in front of the macro.
The traversal
The input is a DeriveInput: the type’s identifier, its generic parameters, and its data — a struct
with named fields, a struct with unnamed fields, a unit struct, an enum with variants, or a union.
Each field carries its type and, if named, its identifier. The macro’s job is to produce code that
mentions every field exactly once, in an order the trait’s semantics permit.
use proc_macro2::TokenStream;
use quote::quote;
use syn::{Data, DeriveInput, Fields};
fn expand(input: &DeriveInput) -> TokenStream {
let name = &input.ident;
let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
let body = match &input.data {
Data::Struct(data) => {
// one token stream per field: an ident for a named field, an index for a tuple field
let accesses: Vec<TokenStream> = match &data.fields {
Fields::Named(f) => f.named.iter().map(|x| {
let id = x.ident.as_ref().unwrap();
quote! { self.#id.describe() }
}).collect(),
Fields::Unnamed(f) => (0..f.unnamed.len()).map(|i| {
let idx = syn::Index::from(i);
quote! { self.#idx.describe() }
}).collect(),
Fields::Unit => Vec::new(),
};
quote! {
let parts: Vec<String> = vec![#(#accesses),*];
parts.join(", ")
}
}
Data::Enum(_) => quote! { unimplemented!() },
Data::Union(_) => quote! { compile_error!("unions are not supported") },
};
quote! {
impl #impl_generics Describe for #name #ty_generics #where_clause {
fn describe(&self) -> String {
format!("{} {{ {} }}", stringify!(#name), #body)
}
}
}
}
Three details in that sketch are load-bearing.
split_for_impl produces the three pieces of a generic header: the parameters as they appear after
impl, the parameters as they appear after the type name, and the where clause. They are not
interchangeable. impl<T: Bound> and Type<T> differ in that the first carries bounds and the
second does not, and a macro that emits impl #generics Trait for #name #generics produces code that
fails to parse because the same token sequence cannot serve both positions.
Field access differs by shape. Named fields are accessed as self.name; unnamed fields as self.0,
which requires constructing a number rather than using an identifier. A tuple index is written as a
bare integer literal in member position, and a literal with any suffix or a decimal point is not a
valid member name there. syn::Index exists to hold that integer and render it as an unsuffixed
literal, so the interpolated token is exactly the one the grammar expects.
The unit struct case has no fields at all, and the recursion must still produce a valid expression.
An empty repetition in quote! expands to nothing, which for a comma-separated list leaves a
trailing comma that may or may not be legal depending on context. This is the most common source of
“works for three fields, fails for zero” in derive macros.
Bounds: the part that is always wrong the first time
The generated impl must be valid for every instantiation of the type, which means the trait bounds
on the impl must be sufficient for the generated body to type-check. A naive macro emits the type’s
own parameters unchanged:
impl<T> Describe for Wrapper<T> {
fn describe(&self) -> String { self.0.describe() }
}
This does not compile, because T has no Describe bound and the body calls describe on a value of
type T. The obvious fix is to add T: Describe. That fix is wrong in two ways.
First, it is too strong. If the type is struct Tagged<T> { tag: &'static str, value: u32 } with a
PhantomData<T>, no field of type T is ever described, and requiring T: Describe makes the impl
unusable for any T that does not implement it — a bound that the trait’s semantics do not need. The
same happens for a field of type u32: u32 needs Describe for the body to call describe on it,
but adding u32: Describe as a where-clause predicate is a promise about a concrete type that the
macro does not control.
Second, it is not general enough. A field of type Vec<T> needs Vec<T>: Describe, which a blanket
impl may supply independently of T, and a field of type Option<Box<T>> needs the bound at that
type. A per-parameter bound states the wrong proposition for every one of those cases. The correct
set is “every field’s type implements the trait”, and expressing that requires the macro to walk the
field types and emit one predicate per type.
where
T: Describe,
Vec<T>: Describe,
Field-type bounds are the standard approach and they have a known cost: a field whose type is private
makes the generated bound mention a private type in a public impl, and the compiler rejects a
private_interfaces violation when that type is not reachable from the impl’s visibility. The
alternative, adding bounds only on the type parameters, is what a hand-written impl would do and is
what most derives approximate.
Lifetimes and the where-clause ordering problem
A type with a lifetime parameter needs that lifetime in the impl header, and a type with a lifetime
bound needs it in the where clause. split_for_impl handles both, but the macro must not reorder
parameters: lifetimes must precede type parameters, and the compiler enforces it with a parse error
that names neither the macro nor the derive.
The subtle case is a lifetime that the generated code uses but the type does not declare. Adding an
impl parameter raises a new question rather than a parse error: if the impl declares an unconstrained
lifetime, no method can obtain a value using it, because Self does not mention it, so a macro that
needs a lifetime for a helper type should declare it on the helper rather than on the impl.
Where-clause predicates also have an ordering constraint relative to the existing where clause: a macro that extends the clause has to splice the existing tokens and append its own rather than rebuild them in a different order, because the clause is a token sequence that the parser expects to be well-formed, and a lifetime predicate must appear after the lifetimes it mentions.
Enums are the hard case
A struct has one shape; an enum has one per variant, and the macro must dispatch on the variant
before it can recurse into fields. That means the generated code contains a match with one arm per
variant, and each arm binds its fields with the pattern the variant declares.
let arms = variants.iter().map(|v| {
let vname = &v.ident;
match &v.fields {
Fields::Named(f) => {
let names: Vec<_> = f.named.iter().map(|x| x.ident.clone().unwrap()).collect();
// the arms must produce the same type, so no trailing comma between parts
quote! {
Self::#vname { #(#names),* } => {
vec![#(::std::format!("{}: {}", stringify!(#names), #names.describe())),*]
.join(", ")
}
}
}
Fields::Unnamed(f) => {
let names: Vec<_> = (0..f.unnamed.len()).map(|i| syn::Ident::new(
&format!("f{}", i), vname.span())).collect();
quote! {
Self::#vname(#(#names),*) => {
vec![#(#names.describe()),*].join(", ")
}
}
}
Fields::Unit => quote! { Self::#vname => { String::new() } },
}
});
Binding names for unnamed fields is where hygiene matters. The generated identifiers are new names
the macro invents, and because the macro emits raw tokens, those names are in the same scope as
anything else in the arm. Choosing f0, f1 risks colliding with nothing in practice, because the
arm’s scope contains only what the pattern binds, but a macro that generates an identifier inside a
user-provided expression has no such guarantee. The general problem is
macro hygiene,
and a procedural macro has none of it: the names it emits are resolved by spelling like any other.
The span attached to a generated identifier determines what an error message underlines. Using the
variant’s span points at the enum; using a fresh call-site span points at the derive attribute;
using the field’s span points at the field. The last is almost always what the user wants, and it is
the reason syn preserves spans on every node.
What a derive cannot do
A derive sees one item. It cannot see the module the item is in, other items in that module, or the trait it is implementing beyond its name. It cannot conditionally generate code based on whether a type implements another trait, because that is a type-level question and the macro has only tokens. Any extra items it emits are appended to the module alongside the impl, and their names are then ordinary items that other derives and ordinary code resolve by name, with the same collision risk as any hand-written helper.
These limits produce the design pattern that most derive-based libraries converge on: the derive generates an implementation that calls a trait method on each field, and the trait’s default methods or a separate manual impl supply the per-type behaviour. The derive is then purely structural, and the semantics live in ordinary code where they can be tested, documented, and reasoned about without running the compiler.
The compile-time alternative for cases where the derive needs real information — a computed table, a size, a hash of the type’s shape — is to have the generated code call a constant function. That splits the work: the macro does the traversal, the constant function does the computation, and the two meet at a call in the generated body. The mechanism on the other side of that call is compile-time function evaluation, and the reason the split is necessary rather than convenient is that the macro’s interface carries tokens and not values.
Haskell’s deriving mechanism is the same idea with the traversal built into the compiler: deriving (Eq, Ord, Show) is a fixed structural recursion over the data declaration, with the compiler
supplying the per-constructor logic. The generality trade is visible in the difference. Haskell’s
deriving is uniform and cannot be extended by a library without a language extension; Rust’s derive
is extensible and each implementation must redo the traversal, including its generic-bound
inference. Neither is strictly better — the built-in version is correct by construction for the
cases it covers, and the library version covers cases the compiler never anticipated.
References
- The Rust Reference — Derive macros — The Rust project
- The Rust Book, chapter 20 — Advanced Traits — The Rust project
- syn crate documentation — docs.rs
- Haskell 2010 Language Report, chapter 11 — Derived instances — Haskell 2010 committee