Skip to content
advanced Phase 10 · Declarative and Procedural Macros

Building a Custom derive

Generate trait implementations automatically from a derive attribute.

1h
0 problems
Topic Progress 0%

Two Halves of Every derive: the Trait and the Macro That Implements It

#[derive(Debug)] feels like one feature, but it is two: a trait with a method the compiler calls, and an impl of that trait. Builtin derives are special-cased inside rustc. A custom derive is the same surface syntax built out of two artifacts you own, and knowing which half is which is the whole mental model.

// crate: shapely — ordinary library, compiled for the target, used at runtime
pub trait Describe {
    fn describe() -> String;
}
// crate: shapely-derive — proc-macro = true, runs in the compiler
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput};

#[proc_macro_derive(Describe)]
pub fn describe_derive(input: TokenStream) -> TokenStream {
    let ast = parse_macro_input!(input as DeriveInput);
    let name = &ast.ident;
    quote! {
        impl shapely::Describe for #name {
            fn describe() -> String { stringify!(#name).to_string() }
        }
    }
    .into()
}
// consumer
use shapely::{Describe, describe_derive};   // trait + macro, one line each

#[derive(Describe)]
struct User { id: u64, email: String }

assert_eq!(User::describe(), "User");

Three mechanics are worth stating precisely because interviews probe them.

The macro receives the item's tokens, not a type-checked program. Expansion happens before type checking, so input is raw syntax: DeriveInput gives you ident, attrs, generics, and data — nothing about field types beyond their syntax. Whatever you generate, the compiler type-checks afterward against your trait.

You emit only the impl, never the struct. The compiler keeps the original item and appends your returned tokens. Returning the struct definition as well produces a duplicate-definition error — a classic first-derive mistake.

The #[proc_macro_derive(Describe)] line declares the macro's name and (later) its helper attributes. That name is what #[derive(...)] resolves against; it must be in scope, exactly like Debug. Note also that derive macros work on structs, enums, and unions — so a correct derive either handles all three shapes or rejects the unsupported ones with a spanned error (chapter 2) rather than generating nonsense.

Where does the runtime half live? In a normal crate, because trait methods must exist in the compiled program: macro crates export nothing but macros. The consumer therefore depends on both crates, and the library author usually re-exports the derive from the main crate so consumers name one dependency — a packaging detail chapter 3 treats properly.

Run cargo expand on that example and the compiler's view becomes unambiguous:

struct User { id: u64, email: String }            // the original item — untouched
impl shapely::Describe for User {                  // your tokens, appended
    fn describe() -> String { "User".to_string() }
}

Namespaces matter here: the trait lives in the type namespace and the derive macro in the macro namespace, which is why one name can be both Describe the trait and Describe in #[derive(Describe)] — a single use shapely::Describe; imports both. It also means derives get no immunity from coherence: hand-write impl Describe for User and derive it, and you get error[E0119: conflicting implementations of trait Describe for type User], exactly as for any duplicate impl.

A question interviewers like: "why a derive instead of a macro that writes the struct too?" Because the struct stays real source. Your editor completes its fields, rust-analyzer navigates them, refactors rename them — the derive only adds behavior while the declaration remains honest code instead of something readers must expand in their heads.

Interview framing: "Write #[derive(Builder)]" is a standard take-home and a common pairing-screen question. Open by splitting trait from macro, show the entry point, then walk field iteration and error handling. The previous topic, How Procedural Macros Work, covered the machinery; here the new ground is item shape, generics, and packaging.

Walking the Fields: DeriveInput, Data, and Errors That Blame the Right Token

The interesting work starts once you parse. DeriveInput splits into three ways to be a type:

let ast = parse_macro_input!(input as DeriveInput);
match &ast.data {
    syn::Data::Struct(s) => { /* s.fields: Named | Unnamed | unit */ }
    syn::Data::Enum(e) => { /* e.variants, each with its own fields */ }
    syn::Data::Union(u) => { /* u.fields.named only */ }
}

Now build a derive that earns its keep — one that lists every field with its type:

#[derive(Describe)]
struct Order { id: u64, customer: String, total_cents: u64 }

Order::describe()   // -> "Order { id: u64, customer: String, total_cents: u64 }"

The macro body branches on shape, rejects what it cannot handle on the user's own tokens, then builds the impl:

let syn::Data::Struct(s) = &ast.data else {
    return syn::Error::new_spanned(&ast.ident, "Describe supports structs only")
        .to_compile_error().into();
};
let syn::Fields::Named(fields) = &s.fields else {
    return syn::Error::new_spanned(&s.fields, "Describe needs named fields")
        .to_compile_error().into();
};

let rows: Vec<_> = fields.named.iter().map(|f| {
    let ident = f.ident.as_ref().unwrap();
    let ty = &f.ty;
    quote! { concat!(stringify!(#ident), ": ", stringify!(#ty)) }
}).collect();

let name = &ast.ident;
quote! {
    impl shapely::Describe for #name {
        fn describe() -> String {
            format!("{} {{ {} }}", stringify!(#name), [#(#rows),*].join(", "))
        }
    }
}
.into()

Notice two habits. Every rejection is a spanned error on the offending tokens — new_spanned(&ast.ident, ...) points the caret at Order, so the user sees error: Describe supports structs only under their own type name. parse_macro_input! already gave you that behavior for malformed syntax; syn::Error extends it to semantic rules your macro invents. A panic! would report "proc macro panicked" with a stack trace through syn internals — technically an error, but one the user cannot act on.

Two pieces of ordinary Rust carry weight inside the macro. The let ... else patterns are refutable — ast.data could be an enum or union — so the compiler forces an else block that diverges, and return ...into() does double duty as both the divergence and our error path. The f.ident.as_ref().unwrap() is sound only because the second let-else already narrowed us to Fields::Named; unnamed fields have no identifier at all, which is precisely the case we rejected one line earlier.

The generated concat!(stringify!(#ident), ": ", stringify!(#ty)) deserves unpacking too: at expansion time #ident and #ty become tokens, so the target program ends up containing concat!(stringify!(id), ": ", stringify!(u64)), which its compiler folds into "id: u64" long before the program runs. Field names and types travel as syntax and cost zero runtime work. And #(#rows),* is quote's repetition operator — iterate rows, emitting the literal , between elements, the same $(...)* idea you met in macro_rules! with a real expression driving it.

Generics are the other correctness trap. Hand-writing impl Describe for Order works for concrete types, but the struct's own generic parameters must ride along:

let mut generics = ast.generics.clone();
generics.make_where_clause().predicates.push(syn::parse_quote!(#ty: std::fmt::Display));
let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();

quote! {
    impl #impl_generics Describe for #name #ty_generics #where_clause { ... }
}

split_for_impl hands back exactly the three fragments impl syntax needs, so #[derive(Describe)] struct Wrapper<T>(T); expands to impl<T> Describe for Wrapper<T> where ... instead of the uncompilable impl Describe for Wrapper<T> with a missing <T> — a perfect illustration of derives being syntax-level: they can only forward what they can see in ast.generics.

Enums get the same treatment per variant: iterate e.variants, generate one branch or arm per variant, and refuse unions early if your concept does not apply. The rule to carry away: parse once, validate explicitly, generate only after every shape you support has been confirmed.

The Missing $crate: Hardcoded Paths and Inert Helper Attributes

Your generated impl has to name your trait. That one requirement causes most of the packaging pain in real derive crates, because proc macros have no $crate metavariable — the macro_rules! token that expands to "whichever crate I am defined in, no matter what the user called it" does not exist for procedural macros.

quote! {
    impl shapely::Describe for #name { ... }   // hardcoded path to the trait's crate
}

Hardcoding is the standard answer, and it has a sharp edge: it resolves against the dependency name in the user's Cargo.toml. If someone writes:

[dependencies]
shape = { package = "shapely", version = "1" }

then impl shapely::Describe fails with error[E0433]: use of undeclared crate or module shapely — in the user's build, blamed on generated code. This is exactly why serde documents that your Cargo.toml must depend on a crate actually named serde. Two mitigations are in wide use:

  • Re-export pattern: the main crate does pub use shapely_derive::Describe; behind a feature, so consumers depend on one crate and the derive always travels with its trait. This is what you should do first.
  • Configurable path: accept #[describe(crate = shape)] at the item level and splice that path into the quote — more work, immune to renaming.

The other half of the title is helper attributes, the field-level configuration that makes derives real:

#[proc_macro_derive(Describe, attributes(describe))]

That second argument registers #[describe(...)] as inert: the compiler stops complaining about unknown attributes on the item, passes them through inside DeriveInput, and they expand to nothing themselves. Unregistered attributes are rejected with cannot find attribute — so #[derive(Describe)] #[describ(skip)] (typo included) fails loudly instead of being ignored.

#[derive(Describe)]
struct User {
    id: u64,
    #[describe(rename = "e-mail")]
    email: String,
    #[describe(skip)]
    internal: Option<Box<dyn std::error::Error>>,
}

Reading them is mechanical: walk field.attrs, keep the ones whose path().is_ident("describe"), then parse each with syn::Meta — meta.path(), meta.require_value(), or parse_nested_meta for #[describe(rename = "...")]-style arguments. Your derive now has a configuration language with zero runtime cost, and every unknown flag can be rejected with Error::new_spanned(&attr, "unknown option").

Two more techniques from production derive crates. First, scope whatever the expansion needs: a helper trait, a use alias, or an extra impl emitted at module level collides the moment a second type in the same module derives the same macro (error: duplicate definitions with name ...). The standard container is an anonymous const:

quote! {
    const _: () = {
        // support items live here — block-scoped, so two derived types never clash
        impl shapely::Describe for #name {
            fn describe() -> String { stringify!(#name).to_string() }
        }
    };
}

Serde's expansion has this shape, and once you have seen it you can read any derive's output. Second, know the expansion order: an attribute macro on the same item runs before any derives attached to it, so #[audit] #[derive(Describe)] struct S; lets #[audit] rewrite the item — including its derive list — before Describe ever sees the tokens. That ordering is how wrapper macros compose.

Interview framing: "What breaks if the consumer renames your crate?" separates people who have shipped a derive from people who have read about one — answer with the missing $crate, the E0433 symptom, and the re-export fix. Pair it with helper attributes: registering them in proc_macro_derive is the detail everyone forgets. Next up: the smart pointers these generated impls often wrap, starting with Box for Heap Allocation.

Quiz

1. A #[derive(Describe)] macro generates its impl, and the user then gets "duplicate definition" errors. What did the macro do wrong?

Question 1 options

2. Your derive must support `struct Wrapper<T>(T);`. Which expansion is correct?

Question 2 options

3. The generated code contains `impl shapely::Describe for User`, but the user's Cargo.toml says `shape = { package = "shapely" }`. What happens?

Question 3 options

4. You write `#[derive(Describe)] #[describ(skip)] email: String` with a typo. What should a well-built derive do?

Question 4 options

Flashcards

Question

What does a derive macro receive, and what is it responsible for returning?

Answer

The item's tokens parsed as syn::DeriveInput — ident, attrs, generics, data (Struct/Enum/Union) — before type checking. It must return only the extra code (usually impl blocks); the compiler keeps the original item, so re-emitting it causes duplicate definitions.

Question

Why does a derive have to forward `ast.generics`?

Answer

Because it only sees syntax: for `Wrapper<T>` the impl header must repeat those parameters. `generics.split_for_impl()` returns (impl_generics, ty_generics, where_clause) ready to splice into `impl #impl_generics Trait for #name #ty_generics #where_clause`, and you can push extra bounds onto a cloned where-clause first.

Question

Why must generated impls hardcode a crate path, and what is the standard fix?

Answer

Proc macros have no `$crate` metavariable, so `impl shapely::Trait` resolves through the consumer's Cargo.toml and breaks if the dependency is renamed (E0433 in the user's file). Standard fix: the main crate re-exports the derive (`pub use shapely_derive::Describe;`) so users depend on one crate; alternatively accept `#[describe(crate = path)]` and splice it in.

Question

What are helper attributes, and how does a derive read them?

Answer

Field/item options like `#[describe(skip)]` registered via `#[proc_macro_derive(Describe, attributes(describe))]`. Registration makes them inert (no unknown-attribute error) and passes them through in DeriveInput; your macro filters `field.attrs` by `path().is_ident("describe")` and parses each with syn::Meta / parse_nested_meta, rejecting unknown options with a spanned error.

Question

What is the split between a derive macro's two artifacts, and where must each live?

Answer

A trait defining the runtime behavior (in an ordinary target-compiled crate) and the proc macro that generates `impl Trait` (in its own proc-macro crate). The consumer depends on both — normally via a re-export from the main crate — because macro crates export only macros and trait methods must exist in the compiled program.

Revision Notes

Key Takeaways

  • 1. A custom derive is two artifacts: a runtime trait in a normal crate and a proc macro that emits only the impl block — the struct itself is emitted by the compiler
  • 2. syn::DeriveInput gives ident/attrs/generics/data; branch over Struct/Enum/Union and Named/Unnamed/unit fields, and forward generics with split_for_impl
  • 3. Every rejection should be syn::Error::new_spanned(...).to_compile_error() on the user's tokens — a macro panic is an error nobody can act on
  • 4. Generated paths are hardcoded because proc macros lack $crate, so a renamed dependency breaks the build; the re-export pattern (pub use derive::Trait) fixes it
  • 5. Helper attributes registered in #[proc_macro_derive(..., attributes(...))] are inert and flow into DeriveInput, giving the derive a zero-cost configuration language

Interview Tips

  • • "Implement #[derive(Builder)]" — open by splitting trait from macro, show parse_macro_input! → validate → quote!, then walk field iteration and generics; that ordering signals you have built one
  • • "What breaks if I rename the crate in Cargo.toml?" — the hardcoded path in generated code resolves via dependency names, so you get E0433 in your file; answer with the missing $crate, then the re-export and crate-path-attribute fixes
  • • "How do you handle generic structs in a derive?" — split_for_impl() splices (impl_generics, ty_generics, where_clause) into the header, and you can push extra bounds onto a cloned where-clause before generating
  • • "How would you let users configure per-field behavior?" — register helper attributes in the derive declaration, read them from field.attrs by path, parse with syn::Meta, and reject unknown options with a spanned error

Cheat Sheet

Building a Custom derive

Skeleton:

#[proc_macro_derive(Describe, attributes(describe))]
pub fn describe_derive(input: TokenStream) -> TokenStream {
    let ast = parse_macro_input!(input as DeriveInput);
    let name = &ast.ident;
    // validate shapes -> syn::Error::new_spanned(...).to_compile_error().into()
    let (ig, tg, wc) = ast.generics.split_for_impl();
    quote! { impl #ig shapely::Describe for #name #tg #wc { ... } }.into()
}

Rules of the road:

Rule Why
Return only the impl compiler keeps the original item; re-emitting it = duplicate definition
Parse with parse_macro_input! malformed input → spanned compile error, no macro panic
Reject unsupported shapes yourself Data::Union, unit structs, unnamed fields: Error::new_spanned on user tokens
Forward generics.split_for_impl() derives see syntax only; missing <T> breaks generic types
Hardcode ::crate::Trait paths no $crate for proc macros — renamed deps break generated paths (E0433)
Re-export the derive from the main crate one dependency for the user; derive always travels with its trait
Register helper attributes attributes(describe) makes them inert + visible in DeriveInput; parse with syn::Meta

Consumer view:

#[derive(Describe)]
struct User {
    id: u64,
    #[describe(rename = "e-mail")] email: String,
    #[describe(skip)] internal: Option<Box<dyn Error>>,
}

Continue Learning

Before You Start

Related Reading