Post

Macro Hygiene and Design: Writing Macros that Compose

Identifiers inside a macro expansion live in a separate namespace from the caller's code. A macro cannot accidentally shadow, capture, or conflict with variables at its call site. Naming collisions are structurally prevented.

Macro Hygiene and Design: Writing Macros that Compose

The expansion invariant guarantees that macro-generated code is type-checked and borrow-checked. The structural recursion invariant guarantees that macro_rules! expansion is predictable. The token-in, token-out invariant guarantees that even the most powerful macros produce compiler-verified output. But there is one more invariant, and it is the one that separates Rust’s macro system from C’s preprocessor at the deepest level: hygiene.

Identifiers introduced inside a macro expansion live in a different syntax context than identifiers at the call site. A macro’s let result = ... cannot collide with the caller’s result. A macro’s internal tmp variable cannot shadow the caller’s tmp. Naming collisions between macro internals and call-site code are structurally prevented. This is not a convention or a best practice — it is a compiler-enforced invariant.

What Happens Without Hygiene

To understand what Rust prevents, consider what C allows. In C, macros are text substitution with no concept of scope:

1
2
3
#define SQUARE(x) x * x

int a = SQUARE(1 + 2);  // expands to: 1 + 2 * 1 + 2 = 5, not 9

Text substitution. No understanding of expressions. The fix — #define SQUARE(x) ((x) * (x)) — is a convention the programmer must remember. The preprocessor will not enforce it.

The naming collision is worse:

1
2
3
4
5
6
#define SWAP(a, b) { int tmp = a; a = b; b = tmp; }

int tmp = 10;
int x = 20;
SWAP(tmp, x);  // expands to: { int tmp = tmp; tmp = x; x = tmp; }
// tmp shadows the outer tmp — silent corruption

The macro introduces tmp, which shadows the caller’s tmp. The result is undefined behavior. Every C programmer learns this the hard way, and the only defense is manual naming discipline.

Rust eliminates both problems by construction. Declarative macros operate on tokens, not text — so SQUARE(1 + 2) cannot produce operator-precedence bugs (the expression 1 + 2 is captured as a single expr token). And hygiene eliminates naming collisions entirely.

The Hygiene Invariant: Variables Cannot Collide

Variables introduced inside a macro_rules! expansion get a unique syntax context. The compiler tracks which context each identifier belongs to, preventing interference:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
macro_rules! compute {
    ($val:expr) => {
        {
            let result = $val * 2;
            result
        }
    };
}

fn main() {
    let result = 100;
    let doubled = compute!(5);
    println!("result: {}, doubled: {}", result, doubled);
}
1
result: 100, doubled: 10

The macro introduces let result = .... The caller has a variable named result. In C, this would be a collision — the macro’s result would shadow the caller’s. In Rust, they live in different syntax contexts. They are different variables despite the same textual name. The caller’s result is untouched.

Prove it more directly:

1
2
3
4
5
6
7
8
9
10
11
12
macro_rules! shadow_test {
    () => {
        let x = "from macro";
        println!("inside macro: {}", x);
    };
}

fn main() {
    let x = "from caller";
    shadow_test!();
    println!("after macro: {}", x);
}
1
2
inside macro: from macro
after macro: from caller

Two variables named x. Two different syntax contexts. No collision. No corruption. The invariant holds.

Items Are Intentionally Not Hygienic

Hygiene applies to local variableslet bindings inside the expansion. But items — functions, structs, traits, modules — are intentionally visible from outside the macro:

1
2
3
4
5
6
7
8
9
10
11
12
macro_rules! make_greeter {
    () => {
        fn greet() {
            println!("Hello from a macro-generated function!");
        }
    };
}

fn main() {
    make_greeter!();
    greet(); // works — items are visible
}
1
Hello from a macro-generated function!

This is deliberate. If macro-generated functions were hygienic, you could never call them. The whole point of generating a function is for the caller to use it. The rule: local variables are hygienic (protected), items are not (visible).

The consequence: a macro that generates an item with a specific name will conflict with the same name at the call site. The compiler catches this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
macro_rules! make_process {
    () => {
        fn process() {
            println!("macro's process");
        }
    };
}

fn process() {
    println!("caller's process");
}

fn main() {
    make_process!(); // ERROR — duplicate definition
}
1
2
3
4
5
6
7
8
error[E0428]: the name `process` is defined multiple times
 --> src/main.rs:3:9
  |
3 |         fn process() {
  |         ^^^^^^^^^^^^ `process` redefined here
...
9 | fn process() {
  | ------------ previous definition of the value `process` here

The invariant draws a clear line: variables are protected from collision automatically; items are visible and the author must manage names deliberately — often by accepting the name from the caller:

1
2
3
4
5
6
7
8
9
10
11
12
macro_rules! make_fn {
    ($name:ident) => {
        fn $name() {
            println!("{} called", stringify!($name));
        }
    };
}

fn main() {
    make_fn!(my_function);
    my_function(); // my_function called
}

Hygiene in Procedural Macros: Explicit Control

Procedural macros replace automatic hygiene with explicit choice. Every token has a span that determines its syntax context:

Span::call_site() — the identifier resolves at the call site. It can see the caller’s variables and will conflict with the caller’s names. Use this when the caller should be able to reference the generated identifier.

Span::mixed_site() — the default for quote!. Local variables are hygienic (isolated), items are visible at the call site. This matches macro_rules! behavior.

1
2
3
4
5
6
7
8
9
use proc_macro2::Span;
use quote::quote;

// In a proc macro:
let var_name = syn::Ident::new("helper", Span::call_site());
// Visible at the call site — caller can use `helper`

let internal = syn::Ident::new("internal", Span::mixed_site());
// Follows macro_rules! hygiene rules

The choice is per-identifier. You decide exactly which names leak to the caller and which stay internal. This is more power than macro_rules! offers — and more responsibility. Get the span wrong and you either create unexpected collisions or invisible identifiers. The compiler enforces the choice you make, but you must make it correctly.

The $crate Invariant: Cross-Crate Name Resolution

When a macro is defined in a library crate and used elsewhere, its expansion needs to reference items from the defining crate. But the user might have renamed the dependency. How does the macro find its own crate?

$crate — a special metavariable that always resolves to the crate where the macro is defined:

1
2
3
4
5
6
7
8
9
10
11
// In crate `my_utils`:
pub fn helper() -> String {
    String::from("help from my_utils")
}

#[macro_export]
macro_rules! call_helper {
    () => {
        $crate::helper()
    };
}
1
2
3
4
5
6
7
// In the user's crate:
use my_utils::call_helper;

fn main() {
    let msg = call_helper!();
    println!("{}", msg); // "help from my_utils"
}

Without $crate, the expansion would produce helper() and the compiler would look for helper in the user’s crate — wrong crate. With $crate, the path always resolves to the defining crate, regardless of how the user imports or renames things. This is the cross-crate invariant: a library macro’s references to its own items are stable no matter where the macro is invoked.

This matters for trait implementations in macros:

1
2
3
4
5
6
7
8
9
10
#[macro_export]
macro_rules! impl_greet {
    ($t:ty) => {
        impl $crate::Greetable for $t {
            fn greet(&self) -> String {
                format!("Hello, I'm a {}", stringify!($t))
            }
        }
    };
}

Without $crate, renaming the crate in Cargo.toml would silently break every macro expansion. With $crate, the module system handles it.

For procedural macros, $crate is not available — proc macros reference their crate by name. The convention is to re-export necessary items from a companion crate.

The Design Invariant: Do the Minimum

The most important macro design principle is also the most invariant-like: if a simpler Rust feature solves the problem, do not write a macro.

Macros are harder to read, harder to debug, and harder to maintain than functions, generics, and traits. Every macro is code the compiler will check but humans will struggle to trace. Use them only when alternatives genuinely cannot express the pattern.

Use generics, not macros, when the pattern is “same logic, different types”:

1
2
3
4
// This is a function. Not a macro. Good.
fn process<T: Display + Clone>(item: T) -> String {
    format!("processed: {}", item)
}

Use default trait implementations when types share behavior with variations. The trait system was designed for this exact problem.

Use From/Into conversions instead of macros that convert between types. These are standard, discoverable, and compose with ? and error handling.

When you do write a macro, keep the expansion minimal. Factor logic into functions the macro calls:

1
2
3
4
5
macro_rules! connect {
    ($url:expr) => {
        $crate::internal::establish_connection($url)
    };
}

The macro bridges syntax to a function call. The function contains the logic, is testable, and is debuggable. The macro expansion is one line.

Use compile_error! for clear diagnostics when input is invalid:

1
2
3
4
5
6
7
8
macro_rules! rgb {
    ($r:expr, $g:expr, $b:expr) => {
        ($r, $g, $b)
    };
    ($($other:tt)*) => {
        compile_error!("rgb! expects exactly three color components: rgb!(r, g, b)")
    };
}

The test for whether a macro is justified: if removing it and using normal Rust makes the code longer but clearer, the normal Rust wins. Macros should eliminate substantial duplication that no other feature can address — not save a few keystrokes.

These Invariants at Scale

The Rust ecosystem’s most-used macros demonstrate every invariant from this series:

serde#[derive(Serialize, Deserialize)] inspects struct fields at compile time and generates serialization code. Uses a proc macro because it needs semantic access to fields (token-in, token-out invariant). The generated code is ordinary Rust that the compiler fully checks (expansion invariant).

thiserror#[derive(Error)] generates Display and Error implementations for error enums. Format strings are validated at compile time. Each variant’s generated code is type-checked (expansion invariant).

clap#[derive(Parser)] turns a struct into a CLI argument parser. Field types determine parsing behavior. The generated parser code faces the full compiler pipeline (expansion invariant).

tokio::main#[tokio::main] transforms async fn main() into a synchronous entry point with the async runtime. An attribute macro that consumes and replaces (token-in, token-out contract). The replacement is compiled like any other main.

Each one uses a macro because no other Rust feature can express the pattern. Each one keeps the expansion minimal. Each one produces code the compiler checks with full rigor.

The Hygiene Invariant, Summarized

MechanismInvariant Enforced
Syntax contextsLocal variables in macros cannot collide with caller variables
Items are visibleMacro-generated functions and types are intentionally usable
$crateLibrary macros always resolve to the correct defining crate
Span::call_site()Proc macro identifiers explicitly opt into call-site visibility
Span::mixed_site()Proc macro identifiers follow macro_rules!-style hygiene
Do the minimumIf a function or generic works, do not write a macro

Hygiene is the final piece of Rust’s macro contract. The expansion invariant ensures generated code is type-safe. The structural recursion invariant ensures macro_rules! expansion is predictable. The token-in, token-out invariant ensures procedural macros produce compiler-verified output. And hygiene ensures that none of this code generation can accidentally corrupt the caller’s namespace. Together, these invariants make Rust’s metaprogramming safe by construction — not by convention, not by discipline, but by the same compiler enforcement that runs through every post in this series. The compiler does not trust your code until it has proved it correct. Macros do not change that equation. They just mean there is more code to prove. And the compiler is thorough.

This post is licensed under CC BY 4.0 by the author.