Skip to content
AgileLogics
8 min read

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 with cargo 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.
  • trybuild lets 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 on struct Wrapper<T>.
  • Using panic! or unwrap() for invalid input, which produces a vague "proc-macro derive panicked" error.
  • Generating paths like Vec or Result unqualified. A user may have shadowed them; emit ::std::vec::Vec or ::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.