Procedural macros — Derive
1. What a derive macro is
#[derive(Debug, Clone)]
struct Point { x: i32, y: i32 }
#[derive(Debug)] calls a procedural macro: a Rust program, run by the compiler, that receives the definition of Point and adds the Debug implementation next to it. The struct itself is not modified.
macro_rules! | Procedural macro | |
|---|---|---|
| Written in | syntax patterns | ordinary Rust code |
| Can analyze arbitrary code | with difficulty | yes (with syn) |
Can build identifiers (get_ + name) | no | yes |
| Location | anywhere | a dedicated crate, of type proc-macro |
| Compile cost | low | syn and quote to compile, macro run on every build |
2. The three kinds of procedural macros
| Kind | Use | What it receives | What it produces |
|---|---|---|---|
| derive | #[derive(MyMacro)] | the struct, enum or union | code added after it (usually an impl) |
| attribute | #[my_macro(args)] | the arguments and the annotated item | code that replaces the item (course macros-attribut) |
| function-like | my_macro!(...) | the tokens between parentheses | any code (course macros-function-like) |
A derive is the most restricted: it can modify nothing, only add. It is also the most predictable for the user.
3. Structure of a `proc-macro` crate
3.1 Creating the crate
cargo new describe_macros --lib
# describe_macros/Cargo.toml
[package]
name = "describe_macros"
version = "0.1.0"
edition = "2024"
[lib]
proc-macro = true # required: makes the crate a macro crate
[dependencies]
syn = { version = "2", features = ["full"] } # Rust code parsing
quote = "1" # code generation
proc-macro2 = "1" # TokenStream usable outside the macro (tests)
3.2 The constraints of a `proc-macro` crate
- It can export only procedural macros: no public function, struct or trait.
- It is compiled for the machine that compiles (not for the target), and run during compilation.
- The types and traits used by the generated code must therefore live in another crate (section 14).
3.3 Using the macro
# my_app/Cargo.toml
[dependencies]
describe_macros = { path = "../describe_macros" }
use describe_macros::Describe;
#[derive(Describe)]
struct User { name: String, age: u32 }
4. `TokenStream`, `syn`, `quote`
source code ──► TokenStream ──syn──► tree (DeriveInput) ──you──► quote! ──► TokenStream ──► compiler
proc_macro::TokenStream: a sequence of tokens, what the compiler gives and takes back.syn: turns tokens into a typed tree (DeriveInput,Field,Type,Expr...).quote!: writes Rust code with "holes" (#name) filled with values, and produces tokens.proc_macro2: the same thing asproc_macro, but usable outside a macro (unit tests);synandquotework with it.
The entry point
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput};
#[proc_macro_derive(Describe)]
pub fn derive_describe(input: TokenStream) -> TokenStream {
let ast = parse_macro_input!(input as DeriveInput); // syntax error -> compile error
let name = &ast.ident;
let code = quote! {
impl #name {
pub fn describe() -> &'static str {
stringify!(#name)
}
}
};
code.into() // proc_macro2::TokenStream -> proc_macro::TokenStream
}
What a `DeriveInput` contains
pub struct DeriveInput {
pub attrs: Vec<Attribute>, // #[...] placed on the type
pub vis: Visibility, // pub, pub(crate)...
pub ident: Ident, // the name of the type
pub generics: Generics, // <T, 'a, const N: usize> and where
pub data: Data, // Struct, Enum or Union, with the fields
}
5. A first complete derive
A Describe derive that adds a method describe(&self) -> String listing the fields and their values.
// describe_macros/src/lib.rs
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, Data, DeriveInput, Fields};
#[proc_macro_derive(Describe)]
pub fn derive_describe(input: TokenStream) -> TokenStream {
let ast = parse_macro_input!(input as DeriveInput);
let name = &ast.ident;
let Data::Struct(data) = &ast.data else {
return syn::Error::new_spanned(&ast.ident, "Describe only applies to structs")
.to_compile_error()
.into();
};
let Fields::Named(fields) = &data.fields else {
return syn::Error::new_spanned(&ast.ident, "Describe requires named fields")
.to_compile_error()
.into();
};
let names: Vec<_> = fields.named.iter().map(|c| c.ident.as_ref().unwrap()).collect();
quote! {
impl #name {
pub fn describe(&self) -> String {
let mut parts = Vec::new();
#(
parts.push(format!("{} = {:?}", stringify!(#names), self.#names));
)*
format!("{} {{ {} }}", stringify!(#name), parts.join(", "))
}
}
}
.into()
}
// my_app
#[derive(Describe)]
struct User { name: String, age: u32 }
fn main() {
let u = User { name: "Ada".into(), age: 36 };
println!("{}", u.describe()); // User { name = "Ada", age = 36 }
}
The generated code (visible with cargo expand):
impl User {
pub fn describe(&self) -> String {
let mut parts = Vec::new();
parts.push(format!("{} = {:?}", "name", self.name));
parts.push(format!("{} = {:?}", "age", self.age));
format!("{} {{ {} }}", "User", parts.join(", "))
}
}
6. Reading the fields
6.1 The three forms of struct
use syn::{Data, Fields};
fn describe_shape(data: &Data) -> &'static str {
match data {
Data::Struct(s) => match &s.fields {
Fields::Named(_) => "struct with named fields", // struct S { a: i32 }
Fields::Unnamed(_) => "tuple struct", // struct S(i32, String);
Fields::Unit => "unit struct", // struct S;
},
Data::Enum(_) => "enum",
Data::Union(_) => "union",
}
}
6.2 What a field contains
pub struct Field {
pub attrs: Vec<Attribute>, // #[...] on the field
pub vis: Visibility,
pub ident: Option<Ident>, // None for a tuple field
pub ty: Type, // the type of the field
// ...
}
6.3 Tuple fields: by position
use syn::Index;
let indices: Vec<Index> = (0..fields.unnamed.len()).map(Index::from).collect();
// quote! { #( self.#indices ),* } -> self.0, self.1, self.2
An interpolated usize would give 0usize; syn::Index gives 0, the correct form after a dot.
6.4 Filtering by type
use syn::{Type, TypePath};
fn is_option(ty: &Type) -> bool {
matches!(ty, Type::Path(TypePath { path, .. })
if path.segments.last().is_some_and(|s| s.ident == "Option"))
}
A macro only sees the syntax: it knows that a field is written Option<String>, not what that type really is. An alias (type MaybeName = Option<String>) or a full path (std::option::Option) are only recognized if you handle them explicitly.
7. Generating with `quote!`: interpolations and repetitions
7.1 Interpolating
let name = &ast.ident; // Ident
let ty: syn::Type = syn::parse_quote!(u32);
let n = 42usize;
let text = "hello";
let code = quote! {
const N: usize = #n; // 42usize
fn f() -> #ty { 0 } // fn f() -> u32
fn g() -> &'static str { #text } // "hello" (a string literal)
impl #name {}
};
Any type that implements ToTokens can be interpolated: Ident, Type, Expr, TokenStream, integers, &str, bool...
7.2 Repeating
let names = vec![quote!(a), quote!(b), quote!(c)];
let types = vec![quote!(i32), quote!(String), quote!(bool)];
quote! {
struct S { #( #names: #types ),* } // a: i32, b: String, c: bool
};
quote! {
#( println!("{}", stringify!(#names)); )* // one line per element, no separator
};
#( ... ),* repeats with a separator, #( ... )* without. All the variables in the same repetition must have the same length.
7.3 Building an identifier
use quote::format_ident;
let field = format_ident!("email");
let getter = format_ident!("get_{}", field); // get_email
let setter = format_ident!("set_{}", field); // set_email
quote! {
pub fn #getter(&self) -> &str { &self.#field }
pub fn #setter(&mut self, v: String) { self.#field = v; }
};
This is what macro_rules! cannot do.
7.4 Building the code in pieces
let mut methods = proc_macro2::TokenStream::new();
for c in fields.named.iter() {
let name = c.ident.as_ref().unwrap();
let ty = &c.ty;
methods.extend(quote! {
pub fn #name(&self) -> &#ty { &self.#name }
});
}
quote! {
impl #type_name { #methods }
};
8. Generics
8.1 The problem
#[derive(Describe)]
struct Wrapper<T> { value: T }
// An impl written as `impl #name { ... }` gives `impl Wrapper { ... }`: ERROR, Wrapper expects a T
8.2 `split_for_impl`
let (impl_generics, ty_generics, where_clause) = ast.generics.split_for_impl();
quote! {
impl #impl_generics #name #ty_generics #where_clause {
// ...
}
}
// impl<T> Wrapper<T> { ... }
// impl<'a, T: Clone, const N: usize> S<'a, T, N> where T: Debug { ... }
split_for_impl correctly separates lifetimes, type parameters, const generics and the where clause.
8.3 Adding a bound
The derive from section 5 calls {:?} on each field: T must be Debug.
use syn::{parse_quote, GenericParam};
let mut generics = ast.generics.clone();
for param in &mut generics.params {
if let GenericParam::Type(t) = param {
t.bounds.push(parse_quote!(::std::fmt::Debug)); // T becomes T: Debug
}
}
let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
This is what the standard library's #[derive(Debug)] does: it adds T: Debug to each parameter.
9. Enums
use syn::{Data, Fields};
#[proc_macro_derive(VariantName)]
pub fn derive_variant_name(input: TokenStream) -> TokenStream {
let ast = parse_macro_input!(input as DeriveInput);
let name = &ast.ident;
let Data::Enum(e) = &ast.data else {
return syn::Error::new_spanned(name, "VariantName only applies to enums")
.to_compile_error()
.into();
};
let arms = e.variants.iter().map(|v| {
let variant = &v.ident;
let text = variant.to_string();
let pattern = match &v.fields {
Fields::Unit => quote!(Self::#variant),
Fields::Unnamed(_) => quote!(Self::#variant(..)),
Fields::Named(_) => quote!(Self::#variant { .. }),
};
quote!(#pattern => #text)
});
let (ig, tg, wc) = ast.generics.split_for_impl();
quote! {
impl #ig #name #tg #wc {
pub fn variant_name(&self) -> &'static str {
match self { #(#arms),* }
}
}
}
.into()
}
#[derive(VariantName)]
enum Message { Quit, Write(String), Move { x: i32, y: i32 } }
Message::Write("a".into()).variant_name(); // "Write"
The pattern depends on the shape of each variant: Self::A, Self::A(..) or Self::A { .. }.
10. Helper attributes
10.1 Declaring them
#[proc_macro_derive(Describe, attributes(describe))]
pub fn derive_describe(input: TokenStream) -> TokenStream { /* ... */ }
attributes(describe) lets the user write #[describe(...)] on the type and on its fields. Without this declaration, the compiler would reject the unknown attribute.
#[derive(Describe)]
#[describe(name = "Account")]
struct User {
name: String,
#[describe(hidden)]
password: String,
#[describe(rename = "years")]
age: u32,
}
10.2 Reading them with `parse_nested_meta`
struct FieldOptions {
hidden: bool,
rename: Option<String>,
}
fn read_options(field: &syn::Field) -> syn::Result<FieldOptions> {
let mut opts = FieldOptions { hidden: false, rename: None };
for attr in &field.attrs {
if !attr.path().is_ident("describe") {
continue; // another attribute (serde, doc...): ignored
}
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("hidden") {
opts.hidden = true; // #[describe(hidden)]
Ok(())
} else if meta.path.is_ident("rename") {
let value: syn::LitStr = meta.value()?.parse()?; // #[describe(rename = "...")]
opts.rename = Some(value.value());
Ok(())
} else {
Err(meta.error("unknown option: expected `hidden` or `rename = \"...\"`"))
}
})?;
}
Ok(opts)
}
10.3 Using them in the generation
let mut lines = Vec::new();
for c in fields.named.iter() {
let opts = match read_options(c) {
Ok(o) => o,
Err(e) => return e.to_compile_error().into(),
};
if opts.hidden {
continue; // the password never appears
}
let ident = c.ident.as_ref().unwrap();
let label = opts.rename.unwrap_or_else(|| ident.to_string());
lines.push(quote! {
parts.push(format!("{} = {:?}", #label, self.#ident));
});
}
A hidden attribute that removes a secret field from the output is a good example of use: it is the kind of mistake an ordinary #[derive(Debug)] cannot avoid.
10.4 With the `darling` crate
For many attributes, the darling crate declares the options as a struct (#[derive(FromField)] #[darling(attributes(describe))] struct FieldOptions { #[darling(default)] hidden: bool, rename: Option<String> }) and generates the reading and the error messages.
11. Compile errors
11.1 Never panic
// Bad: the user sees "proc-macro derive panicked" with no position
let fields = match &ast.data { Data::Struct(s) => &s.fields, _ => panic!("struct expected") };
// Good: a compile error, underlined in the right place
let Data::Struct(s) = &ast.data else {
return syn::Error::new_spanned(&ast.ident, "struct expected").to_compile_error().into();
};
11.2 Choosing the position
| Construction | What is underlined |
|---|---|
syn::Error::new_spanned(&node, "...") | the code of node (a field, a type, an attribute) |
syn::Error::new(ident.span(), "...") | a precise position |
syn::Error::new(Span::call_site(), "...") | the macro call (the #[derive]) |
Underline the faulty element (the field with the unsupported type, the badly written attribute), not the whole struct.
11.3 Several errors at once
let mut errors: Option<syn::Error> = None;
for c in fields.named.iter() {
if let Err(e) = read_options(c) {
match &mut errors {
Some(all) => all.combine(e), // accumulate
None => errors = Some(e),
}
}
}
if let Some(e) = errors {
return e.to_compile_error().into(); // all the errors in one compilation
}
11.4 Structuring with `syn::Result`
#[proc_macro_derive(Describe, attributes(describe))]
pub fn derive_describe(input: TokenStream) -> TokenStream {
let ast = parse_macro_input!(input as DeriveInput);
generate(&ast).unwrap_or_else(|e| e.to_compile_error()).into()
}
fn generate(ast: &DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
// ... ? everywhere ...
Ok(quote! {})
}
The entry point only converts; all the work is in a function that uses ?.
12. Paths in the generated code
The generated code is compiled in the user's crate, with the user's imports.
// Bad: depends on the user's imports
quote! { impl Display for #name { fn fmt(&self, f: &mut Formatter) -> Result { .. } } };
// Good: absolute paths
quote! {
impl ::std::fmt::Display for #name {
fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { /* .. */ Ok(()) }
}
};
A procedural macro has no $crate. For the types of your own library, the generated code writes ::my_lib::Trait: the user must therefore depend on my_lib under that name (see macros-export, section 7.3).
13. Testing a procedural macro
13.1 Usage test
// my_app/tests/describe.rs (or in the crate that re-exports the macro)
use describe_macros::Describe;
#[derive(Describe)]
struct Point { x: i32, y: i32 }
#[test]
fn describes_the_fields() {
assert_eq!(Point { x: 1, y: 2 }.describe(), "Point { x = 1, y = 2 }");
}
A proc-macro crate cannot use its own macros in its unit tests: you test them from another crate (integration tests of the library that re-exports it, or an examples folder).
13.2 Testing errors with `trybuild`
// tests/errors.rs
#[test]
fn compile_errors() {
let t = trybuild::TestCases::new();
t.compile_fail("tests/ui/*.rs"); // each file must fail with the message in the neighboring .stderr
t.pass("tests/ok/*.rs");
}
trybuild compiles small files and compares the error message obtained to an expected .stderr file: this checks that the user gets the right message, in the right place.
13.3 Testing the logic without compiling
The analysis and generation logic can live in a function that takes a proc_macro2::TokenStream: it can then be tested like ordinary code, without a macro.
fn generate2(input: proc_macro2::TokenStream) -> syn::Result<proc_macro2::TokenStream> {
let ast: DeriveInput = syn::parse2(input)?;
Ok(quote! { /* ... */ })
}
#[test]
fn rejects_an_enum() {
let r = generate2(quote! { enum E { A } });
assert!(r.is_err());
}
14. Organizing a library with macros
A proc-macro crate can only export macros. A library that provides some is therefore usually split like this:
my_lib/ ← the library: types, traits, functions; re-exports the macros
my_lib_macros/ ← proc-macro = true: only the macros
my_lib_dsl/ ← (optional) the parser, in a normal crate, reusable elsewhere
// my_lib/src/lib.rs
pub trait Describe { fn describe(&self) -> String; } // the trait lives here
pub use my_lib_macros::Describe; // the derive macro of the same name too
A trait and a derive macro can have the same name (serde::Serialize is both): they live in different namespaces.
Publication order on crates.io: first the crates the others depend on (parser, then macros, then library).
15. Example in Runique
Runique follows exactly the split from section 14:
runique/runique_dsl/ ← parser of the model!{} / extend!{} DSL -> tree (normal crate)
runique/derive_form/ ← proc-macro = true: model!{}, extend!{}, #[form]
runique/ ← the framework; re-exports the macros (use runique::prelude::*)
# runique/derive_form/Cargo.toml (excerpt)
[lib]
proc-macro = true
[dependencies]
runique_dsl.workspace = true
syn = { workspace = true, features = ["extra-traits"] }
quote = "1"
proc-macro2 = "1"
Why is the parser in a separate crate (runique_dsl)? Because a proc-macro crate cannot export anything but macros. The runique makemigrations command must read the same model!{} declarations as the macro to generate the migrations: the parser therefore lives in an ordinary crate, used by both derive_form and the CLI.
Runique's macros are not derives: model!{} and extend!{} are function-like macros, #[form] is an attribute macro. They use the same tools as this course (parse_macro_input!, syn::Error, to_compile_error, quote!). They are presented in the next two courses.
Note: Runique uses syn 3; the examples in this course follow the syn 2 API, the most common in current resources.
16. Derive, `macro_rules!` or hand-written code
Generate getters for a struct.
// 1. By hand: nothing to learn, but repetitive
struct A { name: String, age: u32 }
impl A {
fn name(&self) -> &String { &self.name }
fn age(&self) -> &u32 { &self.age }
}
// 2. macro_rules!: you must repeat the list of fields at the call
macro_rules! getters {
($t:ident { $($c:ident : $ty:ty),* }) => {
impl $t { $( fn $c(&self) -> &$ty { &self.$c } )* }
};
}
struct B { name: String, age: u32 }
getters!(B { name: String, age: u32 });
// 3. Derive: the macro reads the fields itself
// #[derive(Getters)]
// struct C { name: String, age: u32 }
| By hand | macro_rules! | Derive | |
|---|---|---|---|
| Initial effort | none | low | dedicated crate, syn, quote |
| The list of fields | written twice | written twice | written once |
Built names (get_name) | by hand | impossible | format_ident! |
| Per-field attributes | — | no | yes |
| For | a few types | a local repetition | a library, many types |
17. Exercises
Exercise 1 — `FieldNames`
Write a derive that adds fn field_names() -> &'static [&'static str].
// Solution:
#[proc_macro_derive(FieldNames)]
pub fn derive_names(input: TokenStream) -> TokenStream {
let ast = parse_macro_input!(input as DeriveInput);
let name = &ast.ident;
let Data::Struct(s) = &ast.data else {
return syn::Error::new_spanned(name, "struct expected").to_compile_error().into();
};
let names: Vec<String> = s.fields.iter()
.filter_map(|c| c.ident.as_ref().map(|i| i.to_string()))
.collect();
let (ig, tg, wc) = ast.generics.split_for_impl();
quote! {
impl #ig #name #tg #wc {
pub fn field_names() -> &'static [&'static str] { &[#(#names),*] }
}
}
.into()
}
Exercise 2 — setters with `format_ident!`
Add a set_<field> for each field, except those marked #[setters(no)].
// Solution (excerpt):
let setters = fields.named.iter().filter(|c| !marked_no(c)).map(|c| {
let field = c.ident.as_ref().unwrap();
let ty = &c.ty;
let name = quote::format_ident!("set_{}", field);
quote! { pub fn #name(&mut self, v: #ty) { self.#field = v; } }
});
fn marked_no(c: &syn::Field) -> bool {
c.attrs.iter().any(|a| {
a.path().is_ident("setters")
&& a.parse_args::<syn::Ident>().is_ok_and(|i| i == "no")
})
}
Exercise 3 — error in the right place
Make your derive reject a field of type f64, underlining that field.
// Solution (excerpt):
for c in fields.named.iter() {
let ty = &c.ty;
if quote::quote!(#ty).to_string() == "f64" {
return syn::Error::new_spanned(&c.ty, "f64 is not supported: use a Decimal")
.to_compile_error()
.into();
}
}
new_spanned(&c.ty, ...) puts the underline on the type of the field in the user's code.
18. Cheat sheet
[lib]
proc-macro = true
[dependencies]
syn = { version = "2", features = ["full"] }
quote = "1"
proc-macro2 = "1"
#[proc_macro_derive(Name, attributes(name))]
pub fn f(input: TokenStream) -> TokenStream {
let ast = parse_macro_input!(input as DeriveInput);
let name = &ast.ident;
let (ig, tg, wc) = ast.generics.split_for_impl();
quote! { impl #ig ::my_lib::Trait for #name #tg #wc { .. } }.into()
}
Data::Struct(s) / Data::Enum(e) Fields::Named / Unnamed / Unit
#var #( #a: #b ),* #( .. )* format_ident!("get_{}", x) syn::Index::from(i)
attr.path().is_ident("name") attr.parse_nested_meta(|meta| ..)
syn::Error::new_spanned(&node, "..").to_compile_error() e.combine(other)
Key points
- A derive adds code after the type, without modifying it.
- A
proc-macrocrate exports only macros: types and traits go in another crate. synreads,quote!writes;split_for_implfor generics.format_ident!builds names, whichmacro_rules!cannot do.- Declare helper attributes with
attributes(...), read them withparse_nested_meta. - Never
panic!: usesyn::Error::new_spannedon the faulty element. - Absolute paths in the generated code.
- Test usage from another crate, errors with
trybuild.