Procedural macros

Rust · Expert · 20 min

Serde, Tokio's #[tokio::main], clap and thiserror are all proc macros; writing one removes whole classes of boilerplate.

A procedural macro is a Rust function that runs inside the compiler: it receives a TokenStream and returns a new TokenStream. There are three kinds:

  • Derive macros, #[derive(MyTrait)], receive the item and add new items (usually an impl) without changing the original.
  • Attribute macros, #[route(GET, "/")], receive the attribute arguments and the item, and replace the item with their output.
  • Function-like macros, sql!(...), receive arbitrary tokens, like macro_rules! but with full Rust logic.

They must live in their own crate with [lib] proc-macro = true in Cargo.toml; that crate can only export macros. A common layout is mylib (runtime types and traits) plus mylib-derive (the macros), with mylib re-exporting the derive.

Nobody manipulates raw tokens directly. `syn` parses tokens into a syntax tree (DeriveInput, ItemFn, ...) and `quote`'s quote! macro turns Rust-like templates back into tokens, with #var interpolation and #(#items),* repetition.

Quality details that separate a good macro from a painful one:

  • Errors with spans: return syn::Error::new_spanned(node, "message").to_compile_error() instead of panicking, so the error underlines the user's code.
  • Generics: use generics.split_for_impl() so derived impls work on generic types.
  • Absolute paths: emit ::std::fmt::Display or ::mylib::Trait, since the user may have shadowed names.
  • Macros are unhygienic by default (Span::call_site()); create helper identifiers carefully.

Costs: proc macros add compile time (syn is a large dependency) and can make errors harder to read. Prefer a generic function or macro_rules! when they suffice. Test macros with trybuild for expected compile errors and inspect output with cargo expand.

Example

// describe-derive/Cargo.toml: [lib] proc-macro = true, dependencies syn = "2", quote = "1"
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, Data, DeriveInput};

#[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(name, "Describe only supports structs")
            .to_compile_error().into();               // error points at the user's type
    };
    let fields = data.fields.iter().filter_map(|f| f.ident.as_ref()).map(|i| i.to_string());
    let (impl_g, ty_g, where_c) = ast.generics.split_for_impl();
    quote! {
        impl #impl_g #name #ty_g #where_c {
            pub fn describe() -> ::std::string::String {
                ::std::format!("{} {{ {} }}", stringify!(#name), [#(#fields),*].join(", "))
            }
        }
    }.into()
}
// user code: #[derive(Describe)] struct User { id: u32, name: String }
//            User::describe() == "User { id, name }"

Remember

  • Derive macros add items, attribute macros replace the item, function-like macros take any tokens
  • Proc macros live in a proc-macro = true crate and usually use syn + quote
  • Report errors with spans, handle generics, and emit absolute paths

Watch out for

  • Panicking inside the macro on bad input, which gives users an unhelpful error with no location.

Check yourself

  1. How does a derive macro's output relate to the struct it is applied to?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: doc.rust-lang.org/reference/procedural-macros.html