Procedural Macros
Prerequisites: Declarative macros
Procedural macros are Rust functions that run inside the compiler: they take
a stream of tokens and return a new one. They power #[derive(Serialize)],
#[tokio::main] and sqlx::query!. They are more work than macro_rules!
and slow down compilation, so reach for them only when pattern matching on
syntax is not enough — typically when you need to inspect a type's fields.
The three kinds
use proc_macro::TokenStream;
// #[derive(MyTrait)] — appends new items after a struct or enum.
#[proc_macro_derive(MyTrait)]
pub fn my_trait(input: TokenStream) -> TokenStream { input }
// #[my_attr(args)] — replaces the item it is attached to.
#[proc_macro_attribute]
pub fn my_attr(args: TokenStream, item: TokenStream) -> TokenStream { item }
// my_macro!(...) — like macro_rules!, but arbitrary code does the expansion.
#[proc_macro]
pub fn my_macro(input: TokenStream) -> TokenStream { input }
Derive macros can only add code. Attribute macros receive the item and
may rewrite it completely, which is how #[tokio::main] wraps your main.
The proc-macro crate
Procedural macros must live in their own crate with proc-macro = true.
That crate can export only macros, so projects usually pair it with a normal
library crate that defines the traits and re-exports the macros.
# describe_derive/Cargo.toml
[package]
name = "describe_derive"
version = "0.1.0"
edition = "2024"
[lib]
proc-macro = true
[dependencies]
syn = "3"
quote = "1"
syn parses a TokenStream into a syntax tree (DeriveInput for derive
macros). quote! goes the other way: it turns Rust-like code with #var
interpolations back into tokens.
A minimal derive macro
This derive adds an associated function describe() that lists the
struct's field names:
// describe_derive/src/lib.rs
use proc_macro::TokenStream;
use quote::quote;
use syn::{Data, DeriveInput, Fields, parse_macro_input};
#[proc_macro_derive(Describe)]
pub fn derive_describe(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
let name = &input.ident;
let fields: Vec<String> = match &input.data {
Data::Struct(data) => match &data.fields {
Fields::Named(named) => named
.named
.iter()
.map(|f| f.ident.as_ref().unwrap().to_string())
.collect(),
_ => Vec::new(), // tuple and unit structs: no names to list
},
_ => {
return syn::Error::new_spanned(name, "Describe only supports structs")
.to_compile_error()
.into();
}
};
// Carry the type's generics through to the impl block.
let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
let expanded = quote! {
impl #impl_generics #name #ty_generics #where_clause {
pub fn describe() -> String {
let fields: &[&str] = &[#(#fields),*];
format!("{} {{ {} }}", stringify!(#name), fields.join(", "))
}
}
};
expanded.into()
}
#(#fields),* is quote's repetition syntax, mirroring macro_rules!. Each
String is interpolated as a string literal.
Using it from another crate (with describe_derive = { path = "../describe_derive" }):
use describe_derive::Describe;
#[derive(Describe)]
struct User {
id: u64,
name: String,
}
#[derive(Describe)]
struct Wrapper<T> {
inner: T,
}
fn main() {
println!("{}", User::describe());
println!("{}", Wrapper::<i32>::describe());
}
Expected output:
User { id, name }
Wrapper { inner }
Debugging and testing macros
cargo expand(install withcargo install cargo-expand) prints the code after macro expansion — the fastest way to see what you generated.- Report problems with
syn::Error::new_spanned(...).to_compile_error()instead of panicking. The error then points at the user's code, like a normal compiler error. trybuildlets you write tests asserting that certain inputs fail to compile with a specific message.
Common mistakes
- Putting a trait or helper function in the proc-macro crate. The compiler rejects it:
error: `proc-macro` crate types currently cannot export any items other than functions tagged with `#[proc_macro]`, `#[proc_macro_derive]`, or `#[proc_macro_attribute]`
- Ignoring generics. Without
split_for_impl, the derive breaks onstruct Wrapper<T>. - Using
panic!orunwrap()for invalid input, which produces a vague "proc-macro derive panicked" error. - Generating paths like
VecorResultunqualified. A user may have shadowed them; emit::std::vec::Vecor::core::result::Result. - Writing a proc macro where a
macro_rules!macro or a generic function would do. Each proc-macro dependency adds compile time for every user.
Practice
Write a derive macro FieldCount that adds an associated constant
FIELD_COUNT: usize equal to the number of fields in a struct (named or
tuple). #[derive(FieldCount)] struct Point(i32, i32); should give
Point::FIELD_COUNT == 2.
Show solution
// field_count_derive/src/lib.rs (a crate with proc-macro = true)
use proc_macro::TokenStream;
use quote::quote;
use syn::{Data, DeriveInput, parse_macro_input};
#[proc_macro_derive(FieldCount)]
pub fn derive_field_count(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
let name = &input.ident;
let count = match &input.data {
Data::Struct(data) => data.fields.len(),
_ => {
return syn::Error::new_spanned(name, "FieldCount only supports structs")
.to_compile_error()
.into();
}
};
let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
quote! {
impl #impl_generics #name #ty_generics #where_clause {
pub const FIELD_COUNT: usize = #count;
}
}
.into()
}
Fields::len() works for named, tuple and unit structs (unit gives 0).
The usize value is interpolated as a literal, so the constant is computed
entirely at compile time.