The boilerplate trap
You define a struct User with fields id and email. You need a method validate that checks if the email contains an @. You write the method. You define Post with title and body. You need validate to check the title length. You copy-paste the method, change the field names, and tweak the logic. You define Comment. You copy-paste again. Your fingers hurt. You realize you're writing the same pattern over and over, just swapping struct names and field lists.
Rust offers a way out. You write a derive macro. The macro reads the struct definition, inspects the fields, and generates the implementation block automatically. You add #[derive(Validate)] to any struct, and the method appears. You write the logic once. The macro handles the repetition.
Stop copy-pasting. Build the machine.
What a derive macro actually is
A derive macro is a function that runs during compilation. The compiler calls your function when it sees #[derive(MyMacro)] on a type. Your function receives the type's definition as a stream of tokens, transforms that stream, and returns new tokens. The compiler inserts the new tokens into the source code as if you had typed them.
Think of a derive macro as a factory stamp. You feed the stamp a blueprint of a struct. The stamp reads the blueprint, checks the fields, and presses out a new implementation block with methods tailored to that struct. You don't write the implementation for every struct. You write the stamp once.
The macro runs at compile time. It writes the code your program runs.
The skeleton: Cargo.toml and the hook
A derive macro lives in its own crate. The crate must declare itself as a procedural macro. In Cargo.toml, set proc-macro = true in the [lib] section.
[lib]
proc-macro = true
This flag tells Cargo to compile the crate as a procedural macro library. The compiler provides the proc_macro crate automatically. You don't list it as a dependency.
Convention aside: name the crate with a -derive suffix. If the macro is Validate, name the crate validate-derive. This signals to users that the crate provides a derive macro. The macro name in code matches the attribute name, not the crate name.
The macro function needs an attribute to bind it to a name. Use #[proc_macro_derive(Name)]. The function takes a TokenStream and returns a TokenStream.
use proc_macro::TokenStream;
// Bind this function to #[derive(Hello)]
#[proc_macro_derive(Hello)]
pub fn hello(input: TokenStream) -> TokenStream {
// Return the input unchanged for now
input
}
If you forget proc-macro = true, the compiler rejects the crate with E0463 (can't find crate for proc_macro). The proc_macro crate only exists for procedural macro crates.
Set proc-macro = true. Without it, you're just writing a library that the compiler ignores.
Anatomy of the input and output
The TokenStream is a sequence of tokens. Tokens are the smallest units of Rust syntax: identifiers, keywords, punctuation, literals. Whitespace is preserved but ignored by parsers. The input stream contains the struct definition. The output stream contains the generated code.
Parsing a TokenStream manually is tedious. You have to handle nesting, generics, attributes, and error reporting. The ecosystem provides syn and quote for this work. syn parses tokens into a syntax tree. quote builds tokens from Rust-like syntax.
Convention aside: use syn and quote for every derive macro. Reinventing the parser is a rabbit hole with no prize. The community expects macros to use these crates. They handle edge cases, hygiene, and error spans correctly.
Treat TokenStream as opaque text. Use syn to read it. Use quote to write it.
Realistic example: Generating a method
Here is a complete derive macro that generates a to_json method. The method returns a string with the struct name.
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput};
// Bind to #[derive(ToJson)]
#[proc_macro_derive(ToJson)]
pub fn to_json_derive(input: TokenStream) -> TokenStream {
// Parse the input tokens into a typed syntax tree
// parse_macro_input! handles errors and returns a compile error if parsing fails
let input = parse_macro_input!(input as DeriveInput);
// Extract the struct or enum name
let name = &input.ident;
// Generate the implementation block
// quote! builds tokens from Rust-like syntax
// #name interpolates the identifier into the output
let expanded = quote! {
impl #name {
pub fn to_json(&self) -> String {
format!("{{ \"type\": \"{}\" }}", stringify!(#name))
}
}
};
// Convert the generated tokens back to a TokenStream
expanded.into()
}
The parse_macro_input! macro calls syn::parse. If the input isn't a valid struct or enum, syn generates a compile error pointing to the problematic token. The quote! macro builds the output. #name inserts the identifier. stringify!(#name) converts the identifier to a string literal at compile time.
Trust syn and quote. Reinventing the parser is a rabbit hole with no prize.
Parsing fields and generating logic
Derive macros shine when they inspect fields. syn::DeriveInput contains a data field. For structs, data is syn::Data::Struct, which holds syn::Fields. You can iterate over fields to generate code for each one.
This example generates a method that prints every field name and value.
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput, Data, Fields};
#[proc_macro_derive(DebugPrint)]
pub fn debug_print_derive(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
let name = &input.ident;
// Match on the data variant to get struct fields
let fields = match &input.data {
Data::Struct(data_struct) => &data_struct.fields,
_ => panic!("DebugPrint only supports structs"),
};
// Generate a match arm or print statement for each field
// fields.iter() yields Field objects
let field_prints = fields.iter().map(|field| {
let field_name = &field.ident;
// Use format_args! to build the print call
quote! {
println!("{}: {:?}", #field_name, self.#field_name);
}
});
let expanded = quote! {
impl #name {
pub fn debug_print(&self) {
#(#field_prints)*
}
}
};
expanded.into()
}
The #(#field_prints)* syntax repeats the block for each item in the iterator. This is how you generate code dynamically based on the number of fields.
Inspect the struct to generate precise code. The macro knows exactly what it's looking at.
Error handling: Pointing fingers correctly
Macros can fail. If the user applies #[derive(ToJson)] to an enum, and you only support structs, you should report an error. Never use panic! in a macro. Panics produce unhelpful compiler messages. Use syn::Error to generate a compile error with a span.
use syn::{Error, Span};
fn check_struct(data: &syn::Data) -> Result<(), Error> {
match data {
syn::Data::Struct(_) => Ok(()),
_ => {
// Create an error at the span of the derive attribute
// The span points to #[derive(MyMacro)] in user code
Err(Error::new_spanned(data, "MyMacro only supports structs"))
}
}
}
Return the error using to_compile_error(). This converts the syn::Error into a TokenStream that the compiler displays as an error.
let input = parse_macro_input!(input as DeriveInput);
let name = &input.ident;
// Check constraints
if let Err(e) = check_struct(&input.data) {
return e.to_compile_error().into();
}
Convention aside: always use Error::new_spanned or Error::new with a specific span. Point the error to the user's code, not your macro code. The user needs to know where the problem is.
Never panic in a macro. Return a compile error that points to the user's code.
Pitfalls and compiler errors
Derive macros introduce unique failure modes.
If you forget proc-macro = true, you get E0463 (can't find crate for proc_macro). The compiler cannot find the procedural macro API.
If your macro returns the wrong type, you get E0308 (mismatched types). The function must return TokenStream.
Macros are hygienic. Identifiers generated by quote do not capture variables from the caller's scope. This prevents name collisions. If you need to reference a variable from the caller, you must pass it explicitly or use a different macro type.
If syn fails to parse, parse_macro_input! generates an error. If you catch the error and ignore it, the macro might generate invalid code. Always handle parse errors.
Macros run at compile time. Expensive operations slow down compilation. Keep macro logic fast. Avoid heavy computation or I/O.
Macros are code generators. If the generated code is wrong, the compiler blames the macro. Test the output.
Decision: Derive macro vs alternatives
Use a derive macro when you need to generate an impl block based on the structure of a type, like adding methods to a struct or enum.
Use a function when the logic operates on a value at runtime and doesn't need to inspect the type's definition at compile time.
Use a trait when you want to define a shared interface that multiple types can implement manually, without code generation.
Use a declarative macro (macro_rules!) when you need to generate arbitrary code fragments, not just implementations attached to a type.
Pick the tool that matches the job. Derive macros attach behavior to types. Functions transform data. Traits define contracts.