Skip to content
rusthacks.dev
Generics

Writing a Derive Macro in Rust With Syn and Quote Explained

Build a custom derive from scratch with syn, quote and proc-macro2: parse the struct, generate an impl, report errors with spans and test the expansion.

3 min read
Summarize in AI
Flat illustration for the post “Writing a Derive Macro in Rust With Syn and Quote Explained”, composed around macros.

A derive macro takes a struct and writes the impl you were tired of typing. The machinery is a handful of types from two crates, and the first version is about thirty lines. This post builds #[derive(Describe)], a trait that lists a struct's field names, and covers the points where beginners lose an afternoon: crate layout, generics and error spans. It follows macro rules basics and sits in the traits and generics cluster.

Rust 1.90 with syn 2, quote 1 and proc-macro2 1.

Layout

Two crates in one workspace:

Text
describe/            the trait and re-export
describe_derive/     the proc macro
describe_derive/Cargo.tomlTOML
[package]
name = "describe_derive"
version = "0.1.0"
edition = "2024"

[lib]
proc-macro = true

[dependencies]
syn = "2"
quote = "1"
proc-macro2 = "1"
describe/src/lib.rsrust
pub trait Describe {
    fn fields() -> &'static [&'static str];
}

pub use describe_derive::Describe;

The trait and the derive share one name, Describe, in different namespaces, which is why serde::Serialize can be both.

The macro

describe_derive/src/lib.rsrust
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 input = parse_macro_input!(input as DeriveInput);
    let name = &input.ident;
    let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();

    let names: Vec<String> = match &input.data {
        Data::Struct(s) => match &s.fields {
            Fields::Named(f) => f
                .named
                .iter()
                .map(|f| f.ident.as_ref().unwrap().to_string())
                .collect(),
            _ => {
                return syn::Error::new_spanned(&input, "Describe needs named fields")
                    .to_compile_error()
                    .into();
            }
        },
        _ => {
            return syn::Error::new_spanned(&input, "Describe only works on structs")
                .to_compile_error()
                .into();
        }
    };

    quote! {
        impl #impl_generics describe::Describe for #name #ty_generics #where_clause {
            fn fields() -> &'static [&'static str] {
                &[ #( #names ),* ]
            }
        }
    }
    .into()
}

quote! interpolates with #name, and #( ... ),* repeats over the vector. split_for_impl is what keeps struct Wrapper<T> working; without it you generate an impl with no generics and get E0107.

Using it

rust
use describe::Describe;

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

fn main() {
    println!("{:?}", User::fields());
}

It prints ["id", "email"]. Applied to an enum, the user sees "Describe only works on structs" pointed at the enum, which is the span from new_spanned.

Testing

Use trybuild for the failing inputs: it compiles small files and compares the compiler's message with a stored .stderr. A plain integration test covers the expansion. cargo expand shows the generated code while you debug.

Costs and limits

syn with default features compiles quickly; features = ["full"] parses function bodies and costs seconds. Procedural macros cannot see types, only tokens, so a macro cannot know that a field is Option<T> except by looking at the path. Compile time effects are covered in generics and monomorphization. Documentation is in the Rust reference on procedural macros, and syn on docs.rs lists the parsing types.

Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.