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 animpl) 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, likemacro_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::Displayor::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 = truecrate 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.