Procedural macros are Rust’s most powerful metaprogramming tool. They run at compile time, receive Rust code as input (a TokenStream), and produce Rust code as output. This is how #[derive(Debug)], #[derive(Serialize)], Tokio’s #[tokio::main], and Axum’s route handlers all work.
Declarative vs Procedural Macros
macro_rules! (declarative macros) match syntax patterns and substitute them. They’re fast to write but limited in what they can express.
Procedural macros are full Rust code that runs at compile time — they can inspect types, traverse ASTs, generate complex code, and emit compile errors with custom messages.
macro_rules! |
Procedural macros | |
|---|---|---|
| Syntax | Pattern-based | Full Rust code |
| AST access | No | Yes (via syn) |
| Custom errors | No | Yes (via quote::quote!) |
| Separate crate needed | No | Yes |
| Use cases | Simple text substitution | Complex code generation |
Three Types of Procedural Macros
1. Custom #[derive] — implement traits automatically:
#[derive(Debug, Clone, Serialize)] // These are proc macros
struct Config { name: String }
2. Attribute-like — transform any item:
#[tokio::main] // proc macro attribute
#[route(GET, "/")] // proc macro attribute
async fn main() {}
3. Function-like — look like function calls:
let query = sql!("SELECT * FROM users WHERE id = ?", id);
let html = html! { <div class="title">Hello</div> };
Project Setup
Procedural macros must live in a dedicated crate with proc-macro = true:
# my_macros/Cargo.toml
[package]
name = "my_macros"
version = "0.1.0"
edition = "2021"
[lib]
proc-macro = true
[dependencies]
syn = { version = "2.0", features = ["full"] }
quote = "1.0"
proc-macro2 = "1.0"
The crate using your macros depends on it normally:
# my_app/Cargo.toml
[dependencies]
my_macros = { path = "../my_macros" }
Example 1: Custom #[derive] — HelloMacro
The simplest possible derive macro:
my_macros/src/lib.rs:
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput};
#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
let ast = parse_macro_input!(input as DeriveInput);
let name = &ast.ident;
quote! {
impl HelloMacro for #name {
fn hello_macro() {
println!("Hello! I'm a {}", stringify!(#name));
}
}
}
.into()
}
Usage:
use my_macros::HelloMacro;
trait HelloMacro {
fn hello_macro();
}
#[derive(HelloMacro)]
struct Robot;
#[derive(HelloMacro)]
struct Human;
fn main() {
Robot::hello_macro(); // Hello! I'm a Robot
Human::hello_macro(); // Hello! I'm a Human
}
Example 2: Builder Pattern Derive
A real-world derive macro that generates a Builder struct for any struct:
// my_macros/src/lib.rs
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput, Data, Fields};
#[proc_macro_derive(Builder)]
pub fn builder_derive(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
let name = &input.ident;
let builder_name = quote::format_ident!("{}Builder", name);
let fields = match &input.data {
Data::Struct(s) => match &s.fields {
Fields::Named(f) => &f.named,
_ => panic!("Builder only supports named fields"),
},
_ => panic!("Builder only supports structs"),
};
// Generate builder field declarations (all wrapped in Option)
let builder_fields = fields.iter().map(|f| {
let name = &f.ident;
let ty = &f.ty;
quote! { #name: Option<#ty> }
});
// Generate builder setter methods
let setters = fields.iter().map(|f| {
let name = &f.ident;
let ty = &f.ty;
quote! {
pub fn #name(mut self, value: #ty) -> Self {
self.#name = Some(value);
self
}
}
});
// Generate build() method
let build_fields = fields.iter().map(|f| {
let name = &f.ident;
let name_str = name.as_ref().unwrap().to_string();
quote! {
#name: self.#name.ok_or_else(|| format!("field '{}' not set", #name_str))?
}
});
// Generate Default init for builder
let default_fields = fields.iter().map(|f| {
let name = &f.ident;
quote! { #name: None }
});
quote! {
pub struct #builder_name {
#(#builder_fields,)*
}
impl #builder_name {
pub fn new() -> Self {
Self { #(#default_fields,)* }
}
#(#setters)*
pub fn build(self) -> Result<#name, String> {
Ok(#name {
#(#build_fields,)*
})
}
}
impl #name {
pub fn builder() -> #builder_name {
#builder_name::new()
}
}
}
.into()
}
Usage:
#[derive(Debug, Builder)]
struct ServerConfig {
host: String,
port: u16,
max_connections: usize,
}
fn main() {
let config = ServerConfig::builder()
.host("localhost".to_string())
.port(8080)
.max_connections(100)
.build()
.unwrap();
println!("{:?}", config);
// Missing field — returns Err
let bad = ServerConfig::builder()
.host("localhost".to_string())
.build();
println!("{:?}", bad); // Err("field 'port' not set")
}
Example 3: Attribute-Like Macro
Attribute macros receive two token streams: the attribute arguments and the item being annotated.
// my_macros/src/lib.rs
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, ItemFn, LitStr};
/// Usage: #[log_call("INFO")]
#[proc_macro_attribute]
pub fn log_call(attr: TokenStream, item: TokenStream) -> TokenStream {
let level = parse_macro_input!(attr as LitStr).value();
let func = parse_macro_input!(item as ItemFn);
let func_name = &func.sig.ident;
let func_name_str = func_name.to_string();
let vis = &func.vis;
let sig = &func.sig;
let body = &func.block;
quote! {
#vis #sig {
println!("[{}] Calling {}", #level, #func_name_str);
let result = (|| #body)();
println!("[{}] {} returned", #level, #func_name_str);
result
}
}
.into()
}
Usage:
#[log_call("INFO")]
fn process_data(data: &str) -> usize {
data.len()
}
fn main() {
let len = process_data("hello world");
println!("Length: {}", len);
// [INFO] Calling process_data
// [INFO] process_data returned
// Length: 11
}
Example 4: Function-Like Macro
Function-like macros receive a TokenStream of everything inside the parentheses:
// my_macros/src/lib.rs
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse::Parse, parse_macro_input, LitStr, Token};
struct EnvDefault {
var: LitStr,
_comma: Token![,],
default: LitStr,
}
impl Parse for EnvDefault {
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
Ok(EnvDefault {
var: input.parse()?,
_comma: input.parse()?,
default: input.parse()?,
})
}
}
/// env_or!("DATABASE_URL", "postgres://localhost/mydb")
/// Expands to: std::env::var("DATABASE_URL").unwrap_or_else(|_| "...".to_string())
#[proc_macro]
pub fn env_or(input: TokenStream) -> TokenStream {
let EnvDefault { var, default, .. } = parse_macro_input!(input as EnvDefault);
quote! {
std::env::var(#var).unwrap_or_else(|_| #default.to_string())
}
.into()
}
Usage:
let db_url = env_or!("DATABASE_URL", "postgres://localhost/mydb");
let port = env_or!("PORT", "8080");
Inspecting the AST with syn
syn parses Rust code into an AST. Key types:
use syn::{DeriveInput, Data, Fields, Type, Ident, Attribute, Lit, Meta};
fn inspect_derive(input: &DeriveInput) {
// Name of the struct/enum
let name: &Ident = &input.ident;
println!("Name: {}", name);
// Generics
let generics = &input.generics;
// Attributes like #[serde(rename = "foo")]
for attr in &input.attrs {
println!("Attr: {:?}", attr.path().get_ident());
}
match &input.data {
Data::Struct(s) => {
match &s.fields {
Fields::Named(fields) => {
for field in &fields.named {
let field_name = field.ident.as_ref().unwrap();
let field_type = &field.ty;
println!(" Field: {} : {:?}", field_name, quote::quote!(#field_type).to_string());
}
}
Fields::Unnamed(fields) => {
for (i, field) in fields.unnamed.iter().enumerate() {
println!(" .{}: {:?}", i, &field.ty);
}
}
Fields::Unit => println!(" Unit struct"),
}
}
Data::Enum(e) => {
for variant in &e.variants {
println!(" Variant: {}", variant.ident);
}
}
Data::Union(_) => println!(" Union"),
}
}
Emitting Compile Errors
Use syn::Error::new_spanned to emit errors that point to the right source location:
use proc_macro::TokenStream;
use syn::{parse_macro_input, DeriveInput, Data};
#[proc_macro_derive(OnlyForStructs)]
pub fn only_for_structs(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
match &input.data {
Data::Struct(_) => {
// Generate code...
quote::quote!().into()
}
_ => {
// Emit a compile error pointing to the type name
syn::Error::new_spanned(
&input.ident,
"OnlyForStructs can only be derived for structs"
)
.to_compile_error()
.into()
}
}
}
Testing Procedural Macros
Test macros by writing integration tests in the consuming crate. Use trybuild for testing compiler error messages:
# Cargo.toml
[dev-dependencies]
trybuild = "1.0"
// tests/derive_tests.rs
#[test]
fn ui_tests() {
let t = trybuild::TestCases::new();
t.pass("tests/ui/basic.rs");
t.compile_fail("tests/ui/not_a_struct.rs");
}
Real-World Procedural Macro Crates
These popular crates are implemented as procedural macros — studying their source is the best way to learn advanced patterns:
- serde —
#[derive(Serialize, Deserialize)] - tokio —
#[tokio::main],#[tokio::test] - thiserror —
#[derive(Error)] - derive_builder —
#[derive(Builder)] - clap —
#[derive(Parser)]
Summary
| Macro type | Attribute | Use for |
|---|---|---|
| Custom derive | #[proc_macro_derive(Name)] |
Implementing traits on structs/enums |
| Attribute macro | #[proc_macro_attribute] |
Transforming functions, structs, any item |
| Function-like | #[proc_macro] |
DSLs, complex code generation |
The workflow is always the same:
- Receive
TokenStreaminput - Parse with
syninto a typed AST - Generate new code with
quote! - Return the new
TokenStream
Resources
- Rust Book: Procedural Macros
- syn crate documentation
- quote crate documentation
- The Little Book of Rust Macros
- proc-macro-workshop — hands-on exercises
Comments