Declarative Macros: Code that Writes Code Under Contract
Rust macros generate code at compile time — but every expansion must pass the same type checking, borrow checking, and lifetime analysis as hand-written code. The macro writes; the compiler judges.
In most languages, code generation is an escape hatch. C’s preprocessor pastes text with no understanding of types, ownership, or even syntax — and the result is bugs that no tool catches until runtime, if ever. Rust refuses to provide that escape. When a macro in Rust generates code, every line of that output faces the full compiler pipeline: type checking, borrow checking, lifetime analysis. The macro writes; the compiler judges. This is the expansion invariant, and it is the contract that makes Rust’s metaprogramming fundamentally different from text substitution.
The Expansion Invariant
State it plainly: macro output is not exempt from the compiler. Every expansion must produce syntactically valid, type-safe, ownership-correct Rust. No shortcuts. No special treatment. No “but a macro generated it, so let it slide.”
This matters because code generation is inherently dangerous. You are producing code that no human wrote — code that nobody reviewed line by line. In C, #define SQUARE(x) x * x silently produces 1 + 2 * 1 + 2 when called as SQUARE(1 + 2), evaluating to 5 instead of 9. The preprocessor has no concept of expressions. It pastes text and hopes.
Rust macros operate on tokens — parsed syntactic units of the language. When you write a macro, you match on structured tokens (expressions, identifiers, types) and produce structured tokens. The output is then compiled as if you had written it by hand. The compiler does not know or care that a macro generated it. It checks everything.
The Invariant Catches Type Errors in Generated Code
Here is the simplest macro — it takes no arguments and expands to a fixed block:
1
2
3
4
5
6
7
8
9
macro_rules! greet {
() => {
println!("Hello from a macro!");
};
}
fn main() {
greet!();
}
1
Hello from a macro!
macro_rules! defines a declarative macro. The () is the pattern, => separates it from the expansion, and the block on the right is the code the macro produces. The ! in greet!() is how Rust distinguishes macro calls from function calls — a visual signal that code generation is happening.
Now make the expansion produce a type error:
1
2
3
4
5
6
7
8
9
10
macro_rules! add_one {
($val:expr) => {
$val + 1
};
}
fn main() {
let x: i32 = add_one!(5); // works — i32 + i32
let y: i32 = add_one!("hello"); // ERROR
}
1
2
3
4
5
6
7
8
error[E0277]: cannot add `{integer}` to `&str`
--> src/main.rs:3:14
|
3 | $val + 1
| ^ no implementation for `&str + {integer}`
...
9 | let y: i32 = add_one!("hello");
| ----------------- in this macro invocation
The macro expanded "hello" + 1. The compiler sees the expanded code and applies normal type checking — exactly as if you had written "hello" + 1 by hand. The error message even points into the expansion, showing which macro produced the bad code. No special privilege. No exemption.
The Invariant Catches Ownership Violations in Generated Code
The borrow checker does not care who wrote the code:
1
2
3
4
5
6
7
8
9
10
11
12
macro_rules! use_twice {
($val:expr) => {
let a = $val;
let b = $val;
println!("{:?} {:?}", a, b);
};
}
fn main() {
let s = String::from("hello");
use_twice!(s); // ERROR — s moved twice
}
1
2
3
4
5
6
7
8
9
10
error[E0382]: use of moved value: `s`
--> src/main.rs:4:17
|
3 | let a = $val;
| ---- value moved here
4 | let b = $val;
| ^^^^ value used here after move
...
10 | use_twice!(s);
| ------------- in this macro invocation
The macro captured $val and used it in two let bindings. For a String (which does not implement Copy), this is a move followed by a use-after-move. The ownership invariant catches it in the expansion, exactly as it would in hand-written code. The macro is a code generator, not an escape hatch.
Fragment Specifiers: Input Contracts
Macros do not accept arbitrary text. They accept tokens, and you constrain what kinds of tokens using fragment specifiers — compile-time contracts on the input:
1
2
3
4
5
6
7
8
9
10
macro_rules! log {
($level:expr, $msg:expr) => {
println!("[{}] {}", $level, $msg);
};
}
fn main() {
log!("INFO", "server started");
log!("ERROR", "connection failed");
}
1
2
[INFO] server started
[ERROR] connection failed
$level:expr means “capture any valid Rust expression.” $msg:expr does the same. The fragment specifier expr is a contract: pass something that is not a valid expression and the macro rejects it before expansion even happens.
Each specifier constrains a different syntactic category:
$e:expr— any expression$i:ident— an identifier (variable name, function name)$t:ty— a type$p:pat— a pattern$b:block— a{ ... }block$l:literal— a literal value$tt:tt— a single token tree (the most permissive)
This is a two-layer contract. First, fragment specifiers ensure the macro receives valid input. Then, the expansion invariant ensures the macro produces valid output. Both layers are enforced at compile time.
Generating Items Under the Same Rules
The ident specifier captures an identifier — a name the macro can use to generate functions, variables, or types. The generated items face every compiler check:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
macro_rules! make_fn {
($name:ident) => {
fn $name() {
println!("{} was called", stringify!($name));
}
};
}
make_fn!(hello);
make_fn!(goodbye);
fn main() {
hello();
goodbye();
}
1
2
hello was called
goodbye was called
The macro generates two functions. stringify! converts a token to a string literal without evaluating it. Each expansion produces a valid function definition — the compiler checks its types, its body, and its return value like any other function. If the expansion produced a function with a type error, the compiler would reject it.
Multiple Arms, Same Contract
Like match on values, macro_rules! can have multiple arms. The compiler tries each pattern top to bottom and uses the first match:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
macro_rules! calculate {
(add $a:expr, $b:expr) => {
$a + $b
};
(mul $a:expr, $b:expr) => {
$a * $b
};
($a:expr) => {
$a
};
}
fn main() {
println!("{}", calculate!(add 2, 3)); // 5
println!("{}", calculate!(mul 4, 5)); // 20
println!("{}", calculate!(42)); // 42
}
The literal tokens add and mul are matched exactly — token matching, not string comparison. Each arm produces a different expansion, but every expansion faces the same compiler checks.
One difference from match: macro pattern matching is first-match-wins, not exhaustive. If no arm matches, the compiler rejects the invocation:
1
2
3
fn main() {
println!("{}", calculate!(sub 10, 3)); // ERROR
}
1
2
3
4
5
6
7
8
error: no rules expected the token `sub`
--> src/main.rs:16:30
|
1 | macro_rules! calculate {
| ---------------------- when calling this macro
...
16| println!("{}", calculate!(sub 10, 3));
| ^^^ no rules expected this token in macro call
The contract: every invocation must match a pattern, and every expansion must satisfy the compiler. Two layers of enforcement, both at compile time.
Why These Macros Cannot Be Functions
You have been using macros since your first Rust program — println!, vec!, assert_eq!. They exist as macros because the patterns they implement cannot be expressed as functions:
println! accepts a format string and any number of arguments. Rust functions have fixed parameter lists — no variadic arguments. The macro expands each invocation into the correct formatting code, and the compiler verifies format string placeholders match the argument types at compile time:
1
println!("name: {}, age: {}", name, age);
vec! creates a Vec from any number of elements. The count varies per call — impossible with a fixed function signature:
1
2
3
let numbers = vec![1, 2, 3, 4, 5];
let empty: Vec<i32> = vec![];
let repeated = vec![0; 10];
assert_eq! compares two values and includes both expressions in the panic message — a function would only receive evaluated values, not the original expressions:
1
2
assert_eq!(2 + 2, 4);
assert_eq!(2 + 2, 5, "math is broken"); // panics showing both values
Each macro exists because generics and traits cannot express the pattern. But every expansion is ordinary Rust code, fully checked by the compiler. The expansion invariant holds for the standard library’s macros just as it holds for yours.
Invocation Syntax
Macros can be invoked with parentheses, brackets, or braces:
1
2
3
my_macro!(); // parentheses — function-like (println!)
my_macro![]; // brackets — collection-like (vec![])
my_macro!{} // braces — block-like
The choice is convention. vec![] uses brackets because it produces something array-like. println!() uses parentheses because it feels function-like. The compiler treats all three identically — the contract is the same regardless of delimiter.
The Expansion Invariant, Summarized
| Mechanism | Invariant Enforced |
|---|---|
| Token-based expansion | Macros produce structured tokens, not raw text |
| Type checking on output | Expanded code must be type-correct |
| Borrow checking on output | Expanded code must respect ownership rules |
| Lifetime analysis on output | Expanded code must satisfy lifetime constraints |
| Fragment specifiers | Macro inputs are constrained to valid syntactic categories |
| First-match-wins arms | Pattern matching resolves unambiguously at compile time |
Macros in Rust are not a backdoor around the compiler — they are a front door that leads to the same room. Every token a macro produces faces the full weight of type checking, borrow checking, and lifetime analysis. The macro writes code; the compiler judges it by the same standard as everything else. This is the contract that makes code generation safe: you can trust macro-generated code exactly as much as you trust hand-written code, because the compiler applies exactly the same scrutiny to both. Next up: advanced declarative macros — repetition, recursion, and the hard limits of what pattern matching on tokens can achieve.
Stay in the loop
Subscribe via RSS to get new posts on systems, Rust, and cryptography.
Subscribe to RSS