Rust Macros: Declarative and Procedural

Rust macros enable metaprogramming—writing code that writes code. Unlike functions that operate on values at runtime, macros operate on syntax at compile time. This distinction is crucial: macros...

Key Insights

  • Declarative macros use pattern matching for simple code generation, while procedural macros manipulate token streams for complex metaprogramming tasks like custom derives
  • Procedural macros require a separate crate with proc-macro = true, adding build complexity but enabling powerful compile-time code generation
  • Choose declarative macros for straightforward syntax extensions and procedural macros when you need to parse Rust syntax or generate complex trait implementations

Introduction to Rust Macros

Rust macros enable metaprogramming—writing code that writes code. Unlike functions that operate on values at runtime, macros operate on syntax at compile time. This distinction is crucial: macros receive code as input, transform it, and output new code before compilation.

The primary motivation for macros is eliminating repetitive code while maintaining type safety and zero-cost abstractions. Consider the difference between writing repetitive initialization code versus using the vec! macro:

// Without macros - repetitive and error-prone
let mut v = Vec::new();
v.push(1);
v.push(2);
v.push(3);

// With vec! macro - concise and clear
let v = vec![1, 2, 3];

Macros also enable domain-specific languages within Rust. The println! macro, for instance, performs compile-time format string validation—something impossible with regular functions. This compile-time execution means zero runtime overhead while providing powerful abstractions.

Declarative Macros (macro_rules!)

Declarative macros use pattern matching to transform input tokens into output code. They’re defined with macro_rules! and consist of one or more rules that match patterns and expand to code.

Here’s a simple implementation of a vec!-like macro:

macro_rules! my_vec {
    // Match zero or more comma-separated expressions
    ( $( $x:expr ),* ) => {
        {
            let mut temp_vec = Vec::new();
            $(
                temp_vec.push($x);
            )*
            temp_vec
        }
    };
}

// Usage
let v = my_vec![1, 2, 3, 4];

The syntax breaks down as follows:

  • $x:expr captures an expression and binds it to $x
  • $( ... ),* means “match zero or more comma-separated items”
  • The expansion repeats temp_vec.push($x); for each captured expression

Fragment specifiers define what kind of syntax each variable captures:

  • expr - an expression
  • ident - an identifier
  • ty - a type
  • pat - a pattern
  • stmt - a statement
  • block - a block of code

Macros can have multiple arms for different input patterns:

macro_rules! create_function {
    // Match function with arguments
    ($func_name:ident, $($arg:ident: $arg_type:ty),*) => {
        fn $func_name($($arg: $arg_type),*) {
            println!("Function {} called", stringify!($func_name));
        }
    };
    
    // Match function without arguments
    ($func_name:ident) => {
        fn $func_name() {
            println!("Function {} called", stringify!($func_name));
        }
    };
}

create_function!(hello);
create_function!(add, x: i32, y: i32);

The repetition operators * (zero or more) and + (one or more) enable handling variable-length inputs. This makes declarative macros excellent for reducing boilerplate when the transformation follows clear patterns.

Procedural Macros: Overview and Types

Procedural macros operate on the abstract syntax tree (AST) of Rust code, represented as a TokenStream. They’re more powerful than declarative macros but require more setup—they must live in a separate crate with special configuration.

There are three types of procedural macros:

  1. Derive macros - automatically implement traits with #[derive(MyTrait)]
  2. Attribute macros - custom attributes like #[my_attribute]
  3. Function-like macros - look like declarative macros but process tokens procedurally

Setting up a procedural macro crate requires specific Cargo.toml configuration:

[lib]
proc-macro = true

[dependencies]
syn = { version = "2.0", features = ["full"] }
quote = "1.0"
proc-macro2 = "1.0"

The ecosystem libraries are essential:

  • syn - parses Rust tokens into a syntax tree
  • quote - converts Rust syntax back into tokens
  • proc-macro2 - wrapper around the compiler’s proc_macro for testing

Derive Macros

Derive macros are the most common procedural macro type. They automatically generate trait implementations based on struct or enum definitions.

Let’s build a Builder derive macro that generates the builder pattern:

// In the proc-macro crate
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput, Data, Fields};

#[proc_macro_derive(Builder)]
pub fn derive_builder(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);
    let name = &input.ident;
    let builder_name = syn::Ident::new(&format!("{}Builder", name), name.span());
    
    let fields = match &input.data {
        Data::Struct(data) => {
            match &data.fields {
                Fields::Named(fields) => &fields.named,
                _ => panic!("Builder only works with named fields"),
            }
        }
        _ => panic!("Builder only works with structs"),
    };
    
    let builder_fields = fields.iter().map(|f| {
        let name = &f.ident;
        let ty = &f.ty;
        quote! { #name: Option<#ty> }
    });
    
    let builder_methods = fields.iter().map(|f| {
        let name = &f.ident;
        let ty = &f.ty;
        quote! {
            pub fn #name(mut self, #name: #ty) -> Self {
                self.#name = Some(#name);
                self
            }
        }
    });
    
    let build_fields = fields.iter().map(|f| {
        let name = &f.ident;
        quote! {
            #name: self.#name.ok_or(concat!("Field ", stringify!(#name), " not set"))?
        }
    });
    
    let expanded = quote! {
        impl #name {
            pub fn builder() -> #builder_name {
                #builder_name {
                    #(#builder_fields: None),*
                }
            }
        }
        
        pub struct #builder_name {
            #(#builder_fields),*
        }
        
        impl #builder_name {
            #(#builder_methods)*
            
            pub fn build(self) -> Result<#name, &'static str> {
                Ok(#name {
                    #(#build_fields),*
                })
            }
        }
    };
    
    TokenStream::from(expanded)
}

Usage is clean and intuitive:

#[derive(Builder)]
struct User {
    name: String,
    age: u32,
    email: String,
}

// Generated builder pattern
let user = User::builder()
    .name("Alice".to_string())
    .age(30)
    .email("alice@example.com".to_string())
    .build()
    .unwrap();

Attribute and Function-like Procedural Macros

Attribute macros attach to items and transform them. They’re perfect for framework-style annotations:

use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, ItemFn, LitStr};

#[proc_macro_attribute]
pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream {
    let args = parse_macro_input!(attr as syn::AttributeArgs);
    let func = parse_macro_input!(item as ItemFn);
    
    let func_name = &func.sig.ident;
    
    // Extract method and path from attributes
    let method = &args[0]; // GET, POST, etc.
    let path = &args[1];   // "/users"
    
    let expanded = quote! {
        #func
        
        inventory::submit! {
            Route {
                method: stringify!(#method),
                path: #path,
                handler: #func_name,
            }
        }
    };
    
    TokenStream::from(expanded)
}

Function-like procedural macros resemble declarative macros but process tokens programmatically:

#[proc_macro]
pub fn sql(input: TokenStream) -> TokenStream {
    let query = parse_macro_input!(input as LitStr);
    let query_str = query.value();
    
    // Perform compile-time SQL validation
    if !query_str.to_uppercase().starts_with("SELECT") {
        return syn::Error::new(
            query.span(),
            "Only SELECT queries are supported"
        ).to_compile_error().into();
    }
    
    // Generate runtime query code
    let expanded = quote! {
        {
            let query = #query;
            // Execute query...
            query
        }
    };
    
    TokenStream::from(expanded)
}

Best Practices and Debugging

Macro hygiene prevents variable name collisions. Declarative macros are hygienic by default—variables introduced in the expansion don’t conflict with surrounding code. Procedural macros require manual span management for proper error reporting.

Always provide helpful error messages using spans:

use syn::spanned::Spanned;

// Good error reporting
return syn::Error::new(
    field.span(),
    "Builder requires all fields to have named identifiers"
).to_compile_error().into();

For debugging, cargo expand shows the expanded macro output:

cargo install cargo-expand
cargo expand --lib my_module

This reveals exactly what code your macros generate. For declarative macros, use trace_macros!:

trace_macros!(true);
my_vec![1, 2, 3];
trace_macros!(false);

Choose declarative macros when:

  • The transformation follows simple pattern matching
  • You don’t need to parse complex Rust syntax
  • Hygiene is important

Choose procedural macros when:

  • Implementing custom derives
  • Parsing struct/enum definitions
  • Generating complex code based on attributes
  • Needing precise error reporting with spans

Conclusion

Declarative macros excel at simple, pattern-based code generation with minimal setup. They’re perfect for eliminating repetitive syntax and creating DSLs. Procedural macros provide full control over token manipulation, enabling sophisticated compile-time code generation at the cost of increased complexity.

The Rust ecosystem heavily leverages both types. Serde uses derive macros for serialization, Tokio uses attribute macros for async runtime setup, and countless libraries use declarative macros for ergonomic APIs. Understanding when and how to use each type is essential for advanced Rust development.

Start with declarative macros for simple use cases. Graduate to procedural macros when you need to parse Rust syntax or generate trait implementations. Both are powerful tools that, when used appropriately, make Rust code more maintainable and expressive while maintaining its zero-cost abstraction guarantee.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.