Jump to content

Declarative vs Procedural Macros: How Rust Keeps Metaprogramming Safe

From JOHNWICK

There’s a moment every Rust developer goes through: you write the same boilerplate struct implementations for the tenth time and think, “There must be a better way.” That’s where macros come in — Rust’s answer to code generation. But unlike C++ templates or Python metaclasses, Rust’s macros are safe, structured, and visible.
And that’s not an accident. It’s one of the most carefully engineered parts of the language — designed to make metaprogramming powerful but predictable. Let’s unpack how Rust keeps this magic clean, and how the two types of macros — declarative and procedural — work under the hood.

The Philosophy: “Metaprogramming Without Madness” In languages like C or C++, macros are essentially glorified text replacement. They’re dumb. They don’t understand types, scope, or syntax trees — they just smash text into other text and pray it compiles. Rust said never again. 
Instead, macros in Rust are syntactic transformations. That means they operate on the abstract syntax tree (AST) — the compiler’s structured representation of your code — not raw text.

This lets Rust enforce rules like:

  • Hygiene (macros don’t accidentally shadow variables),
  • Type-awareness (macros can expand safely in typed contexts),
  • And compiler integration (macros see the same syntax the compiler does).

In other words, macros can generate code without breaking the compiler’s understanding of it.

Architecture Overview: Inside Rust’s Macro System When you write Rust code, your file goes through the compiler pipeline roughly like this:

Source Code
   ↓
Tokenization (Lexer)
   ↓
Parsing → AST
   ↓
Macro Expansion
   ↓
Type Checking → Borrow Checking → Codegen

The magic happens in the macro expansion phase.
At this stage, the compiler pauses normal parsing to detect macros (things like println!, derive!, or custom ones), expands them into valid AST fragments, then resumes parsing as if they were written manually. That’s why you can write this:

println!("Hello, {}!", "world");

and Rust treats it as if you wrote:

{
    use std::io::Write;
    std::io::_print(format_args!("Hello, {}!", "world"));
}

The println! macro was expanded into that block by the compiler before type checking even began.

Declarative Macros (macro_rules!): Pattern Matching on Syntax Declarative macros are the old-school but still brilliant part of Rust’s design.
They’re defined with macro_rules! and work like pattern matching on code. Think of them as “find-and-replace,” but for structured syntax instead of text. Here’s a simple example:

macro_rules! say_hello {
    () => {
        println!("Hello, Rustacean!");
    };
}

fn main() {
    say_hello!();
}

When compiled, say_hello!() expands into:

println!("Hello, Rustacean!");

But declarative macros can go deeper — they can take parameters, match patterns, and generate arbitrary Rust code.

Example — a mini vector macro:

macro_rules! my_vec {
    ( $( $x:expr ),* ) => {
        {
            let mut v = Vec::new();
            $( v.push($x); )*
            v
        }
    };
}


fn main() {
    let nums = my_vec![1, 2, 3, 4];
    println!("{:?}", nums);
}

Here’s what’s happening:

  • The pattern $( $x:expr ),* matches a comma-separated list of expressions.
  • The repetition $( v.push($x); )* expands for each matched $x.
  • The macro builds a vector — all without touching text.

Declarative macros are expanded syntactically, not semantically.
That means the compiler doesn’t “understand” their logic — it just transforms the code before type-checking happens. Procedural Macros: Compiler Plugins with Full Power If declarative macros are pattern-based, procedural macros are compiler-level functions. 
They take in a stream of tokens, manipulate them as syntax trees, and return new code to be compiled. They come in three flavors:

  • Function-like macros: e.g. my_macro!(...)
  • Derive macros: e.g. #[derive(MyTrait)]
  • Attribute macros: e.g. #[route("/")] fn index() {}

Let’s take a derive macro as an example — the kind you see everywhere in Rust crates:

use quote::quote;
use syn::{parse_macro_input, DeriveInput};

#[proc_macro_derive(Hello)]
pub fn hello_derive(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
    let input = parse_macro_input!(input as DeriveInput);
    let name = &input.ident;
    let expanded = quote! {
        impl #name {
            pub fn hello() {
                println!("Hello, {}!", stringify!(#name));
            }
        }
    };
    expanded.into()
}

Now, if you apply it to a struct:

#[derive(Hello)]
struct Rustacean;

fn main() {
    Rustacean::hello();
}
The compiler expands it as if you wrote:
impl Rustacean {
    pub fn hello() {
        println!("Hello, Rustacean!");
    }
}

Here’s the key difference:
Procedural macros are actual Rust functions that manipulate syntax trees — parsed by the syn crate and generated via quote!.

Code Flow Diagram

          ┌────────────────────────┐
          │   Source Code (user)   │
          └────────────┬───────────┘
                       │
                       ▼
            ┌──────────────────────┐
            │ TokenStream detected │
            └────────────┬─────────┘
                       │
         ┌─────────────┴────────────┐
         │ Declarative (macro_rules!)│
         │   Pattern → Expansion     │
         │                           │
         │ Procedural (proc_macro)   │
         │   TokenStream → AST → Code│
         └─────────────┬────────────┘
                       ▼
              ┌──────────────────┐
              │ Compiler resumes │
              │ normal parsing   │
              └──────────────────┘

Why Two Systems?

The split isn’t accidental.
Declarative macros are fast, hygienic, and simple — ideal for most use cases.
Procedural macros are powerful, unsafe (in a sense), and slow — meant for library authors who need deep compiler integration. In fact, Clippy, Serde, Diesel, and many Rust frameworks rely heavily on procedural macros for generating code that would otherwise be thousands of lines. The two-tier system is Rust’s design tradeoff between ergonomics and safety. The Real Reason Behind the Design Rust’s macro system isn’t about saving keystrokes.
It’s about trust boundaries — making sure code generation doesn’t violate the compiler’s safety model.

In C++, metaprogramming can silently break your type system. 
In Rust, every macro expansion is still subject to:

  • Borrow checking
  • Type checking
  • Lifetime inference
  • Visibility rules

Macros generate code, but that code still goes through the same compiler stages as anything else.

That’s why Rust macros feel magical — but they’re not.
They’re just structured transformations with discipline. The Future: Macros 2.0 and Compiler Plugins The Rust team is evolving macros even further — with Macros 2.0 already on the roadmap. 
The goal: unify declarative and procedural macros under a more powerful, yet simpler API.

In the long term, this could mean:

  • Better IDE integration,
  • More predictable expansion errors,
  • And possibly typed macros (yes, really).

Imagine writing macros that know the type of their input — safely.

Final Thoughts

Rust’s macro system is the antidote to unsafe metaprogramming.
It gives developers the power of code generation, automation, and abstraction — without losing the compiler’s safety guarantees. Every time you use serde_derive, wasm_bindgen, or tokio::main, you’re watching that architecture in action:
declarative where possible, procedural where necessary. Rust didn’t just make macros safer.
It made metaprogramming trustworthy again.

Read the full article here: https://medium.com/@theopinionatedev/declarative-vs-procedural-macros-how-rust-keeps-metaprogramming-safe-ed5a991be9c9