Telex logo Telex

Rust Deep Dives #7: Declarative Macros Aren't As Scary As They Look

Post 7 of 8 in Rust Deep Dives. Companion series: Rust Patterns That Matter.

Previous: #6: Serde Patterns | Next: #8: Cargo Features

Declarative macros have a reputation. People see $($thing:tt)* and assume they've wandered into a dark corner of the language best left to library authors. That's a shame, because macro_rules! is one of the most practical tools in Rust once you understand the handful of ideas it's built on. This post walks through those ideas, shows macros you'd actually write, and talks about when to stop.

When a function isn't enough

Most of the time, a function is exactly what you want. Functions are typed, they show up in stack traces, they're easy to read. But there are a few situations where functions hit a wall.

The first is variadic arguments. Rust functions take a fixed number of parameters. If you want to accept two items, or five, or twenty, you need a different approach. This is why vec![1, 2, 3] is a macro — there's no function signature that accepts "any number of expressions separated by commas."

The second is boilerplate reduction across types. If you're implementing the same trait for ten types and the body is identical except for the type name, a function can't help you. You need something that operates on syntax, not values.

The third is code that needs to capture context. A macro like file!() or line!() embeds information from the call site. A function call can't know where it was called from without you passing that information explicitly.

That's the key distinction: functions operate on values at runtime, macros operate on tokens at compile time. A macro receives a stream of syntax tokens, matches them against patterns, and produces new tokens that the compiler then processes as normal Rust code. Nothing happens at runtime. By the time your program runs, every macro has already been expanded into regular Rust.

The anatomy of a macro

Let's look at a macro you've probably wished existed: a hashmap! literal. The standard library gives you vec![] but no equivalent for maps. Here's how you'd write one:

macro_rules! hashmap {
    ($($key:expr => $value:expr),* $(,)?) => {
        {
            let mut map = std::collections::HashMap::new();
            $(
                map.insert($key, $value);
            )*
            map
        }
    };
}

There's a lot packed into those few lines. Let's take it apart.

The first line, macro_rules! hashmap, declares the macro's name. When someone writes hashmap!, this is what the compiler looks up.

Inside the curly braces is a single rule (macros can have multiple rules, but this one only needs one). Every rule has two parts separated by =>: a matcher on the left and an expansion (sometimes called "transcriber") on the right.

The matcher is ($($key:expr => $value:expr),* $(,)?). Reading from the inside out: $key:expr means "capture any expression and bind it to $key." The :expr part is a fragment specifier — it tells the macro what kind of syntax to expect. Then => is a literal token that must appear between each key and value. The $(...),* wrapper means "repeat this pattern zero or more times, separated by commas." And $(,)? allows an optional trailing comma.

The expansion is the right side of =>. It's the code that gets produced. The outer { ... } creates a block expression so the macro can declare a local variable and return it. Inside, $( map.insert($key, $value); )* repeats the insert call once for each captured key-value pair.

Using it looks exactly like you'd hope:

let scores = hashmap! {
    "alice" => 10,
    "bob" => 20,
    "carol" => 30,
};

After expansion, the compiler sees a block that creates a HashMap, inserts three entries, and returns it. No runtime cost beyond what you'd write by hand.

The fragment specifiers

Fragment specifiers tell the macro parser what kind of token tree to expect in each position. Here are the ones you'll actually use:

expr — expressions

The most common specifier. Matches any Rust expression: a literal, a function call, a block, an arithmetic operation, anything that produces a value.

macro_rules! double {
    ($e:expr) => {
        $e + $e
    };
}

let x = double!(5);         // 10
let y = double!(2 + 3);     // 10, not 7 — expr is parsed as a unit

That second example is important. Because expr captures the entire expression 2 + 3 as one unit, the expansion is (2 + 3) + (2 + 3), not 2 + 3 + 2 + 3. This is one of the ways macro_rules! avoids the classic C-macro pitfalls.

ident — identifiers

Matches a single identifier: a variable name, a type name, a function name. This is what you use when the macro needs to generate names.

macro_rules! make_getter {
    ($field:ident, $ty:ty) => {
        fn $field(&self) -> &$ty {
            &self.$field
        }
    };
}

ty — types

Matches a type expression: u32, String, Vec<u8>, &'a str. You saw it in the getter example above. It's essential whenever your macro needs to work with a type that the caller specifies.

tt — token tree

The most flexible specifier. Matches a single token or a balanced group of tokens in parentheses, brackets, or braces. When you don't know what kind of syntax to expect, tt is the escape hatch. The $($t:tt)* pattern matches literally anything.

macro_rules! pass_through {
    ($($t:tt)*) => {
        $($t)*
    };
}

pass_through!(let x = 42;); // expands to: let x = 42;

literal, pat, path, stmt, block, item

These are more specialized. literal matches literal values like 42 or "hello". pat matches patterns (as in match arms). path matches type paths like std::io::Error. stmt matches statements. block matches { ... } blocks. And item matches top-level items like function definitions or struct declarations.

In practice, you'll use expr, ident, ty, and tt for 90% of macros. The others exist for when you need precision.

Repetition patterns

Repetition is where macros pull ahead of functions. The syntax is $(...) sep rep, where sep is an optional separator token and rep is either * (zero or more) or + (one or more).

// Zero or more expressions, separated by commas
($($e:expr),*)

// One or more identifiers, separated by commas
($($name:ident),+)

// Key-value pairs separated by commas, trailing comma allowed
($($k:expr => $v:expr),* $(,)?)

In the expansion, you use the same $(...)* or $(...)+ syntax to repeat the output for each captured element. The number of repetitions in the expansion must match the number in the matcher.

macro_rules! print_all {
    ($($item:expr),+) => {
        $(
            println!("{}", $item);
        )+
    };
}

print_all!("hello", 42, 3.14);
// Expands to:
// println!("{}", "hello");
// println!("{}", 42);
// println!("{}", 3.14);

Nested repetition works too. If your matcher has $( $($inner:expr),+ );+, the expansion can nest the $( )+ blocks the same way. This gets complex fast, which is why you should keep macros as simple as you can.

macro_rules! matrix {
    ( $( [ $($val:expr),* ] ),+ $(,)? ) => {
        vec![
            $(
                vec![$($val),*],
            )+
        ]
    };
}

let m = matrix![
    [1, 2, 3],
    [4, 5, 6],
    [7, 8, 9],
];
// m is Vec<Vec<i32>>

Multiple arms

Just like a match expression, a macro can have multiple rules. The compiler tries them in order and uses the first one that matches. This lets you give a single macro different calling conventions.

macro_rules! log {
    // log!("message")
    ($msg:expr) => {
        eprintln!("[LOG] {}", $msg);
    };

    // log!("user: {}", name)
    ($fmt:expr, $($arg:expr),+) => {
        eprintln!(concat!("[LOG] ", $fmt), $($arg),+);
    };

    // log!(Level::Warn, "something happened")
    ($level:expr, $fmt:expr $(, $arg:expr)*) => {
        eprintln!(concat!("[{:?}] ", $fmt), $level $(, $arg)*);
    };
}

Order matters. If you put the most general pattern first, it'll match everything and the specific patterns will never be reached. Put the most specific patterns first, and fall through to the general one at the end. Think of it like function overloading, but resolved at compile time by pattern matching on token structure.

A common idiom is using a "base case + recursive case" pattern for macros that need to process items one at a time:

macro_rules! count {
    () => { 0 };
    ($head:tt $($tail:tt)*) => {
        1 + count!($($tail)*)
    };
}

let n = count!(a b c d); // 4

Four practical macros you'd actually write

1. Collection literals: hashmap! and btreemap!

We already saw hashmap!. Here's the same idea for BTreeMap, and a slightly more polished version that pre-allocates capacity:

macro_rules! hashmap {
    ($($key:expr => $value:expr),* $(,)?) => {{
        let _cap = count!($($key)*);
        let mut map = std::collections::HashMap::with_capacity(_cap);
        $( map.insert($key, $value); )*
        map
    }};
}

macro_rules! btreemap {
    ($($key:expr => $value:expr),* $(,)?) => {{
        let mut map = std::collections::BTreeMap::new();
        $( map.insert($key, $value); )*
        map
    }};
}

let users = hashmap! {
    1 => "alice",
    2 => "bob",
    3 => "carol",
};

let sorted = btreemap! {
    "z" => 26,
    "a" => 1,
    "m" => 13,
};

These macros save a few lines each time you create a map literal. That doesn't sound like much, but in test code where you're constructing expected values constantly, the noise reduction adds up.

2. ensure! — like assert! but returns Err

The assert! macro panics on failure. That's fine in tests, but in application code you usually want to return an error instead. The ensure! pattern (used by the anyhow crate) does exactly that:

macro_rules! ensure {
    ($cond:expr, $msg:expr) => {
        if !$cond {
            return Err(format!($msg).into());
        }
    };
    ($cond:expr, $fmt:expr, $($arg:expr),+) => {
        if !$cond {
            return Err(format!($fmt, $($arg),+).into());
        }
    };
}

A function can't do this because return in a function body returns from that function. In a macro expansion, return returns from the function at the call site. That's a fundamental capability that only macros have.

fn parse_port(s: &str) -> Result<u16, Box<dyn std::error::Error>> {
    let port: u16 = s.parse()?;
    ensure!(port > 0, "port must be positive");
    ensure!(port <= 65535, "port {} out of range", port);
    Ok(port)
}

3. impl_display! for multiple types

If you have several newtypes and you want them all to display their inner value, the trait implementation is identical for each one. Writing it out five times is tedious. A macro handles the repetition:

macro_rules! impl_display {
    ($($t:ty),+ $(,)?) => {
        $(
            impl std::fmt::Display for $t {
                fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
                    write!(f, "{}", self.0)
                }
            }
        )+
    };
}

struct UserId(u64);
struct OrderId(u64);
struct SessionToken(String);

impl_display!(UserId, OrderId, SessionToken);

This is one of the most common uses of macros in real codebases. Any time you're copy-pasting a trait impl and only changing the type name, a macro is the right call. The standard library does this extensively — look at how numeric traits are implemented for u8, u16, u32, etc.

4. Builder field macro

Builder patterns involve a lot of nearly-identical setter methods. A macro can generate them:

macro_rules! builder_field {
    ($name:ident, $ty:ty) => {
        pub fn $name(mut self, value: $ty) -> Self {
            self.$name = Some(value);
            self
        }
    };
}

struct ServerConfig {
    host: Option<String>,
    port: Option<u16>,
    max_connections: Option<usize>,
    timeout_ms: Option<u64>,
}

impl ServerConfig {
    fn new() -> Self {
        ServerConfig {
            host: None,
            port: None,
            max_connections: None,
            timeout_ms: None,
        }
    }

    builder_field!(host, String);
    builder_field!(port, u16);
    builder_field!(max_connections, usize);
    builder_field!(timeout_ms, u64);
}

let config = ServerConfig::new()
    .host("localhost".into())
    .port(8080)
    .max_connections(100)
    .timeout_ms(5000);

Each builder_field! invocation expands to a complete setter method. Four lines of macro calls replace sixteen lines of nearly-identical methods. And if you need to change the setter convention later (say, taking impl Into<T> instead of T), you change it once in the macro definition.

Debugging macros

Macros are harder to debug than functions. When something goes wrong, the compiler error points at the macro call site, and the message often references code you didn't write. Here are the tools that help.

cargo expand

The single most useful tool for macro debugging. Install it with cargo install cargo-expand, then run:

# Expand all macros in your crate
cargo expand

# Expand macros in a specific module
cargo expand module_name

# Expand macros in a specific test
cargo expand --test test_name

This shows you exactly what your macro expanded to. If the compiler is complaining about a type mismatch inside a macro, cargo expand will show you the generated code so you can see where the types don't line up.

trace_macros!

A nightly-only feature that prints each macro invocation and its expansion during compilation:

#![feature(trace_macros)]

trace_macros!(true);
let m = hashmap! { "a" => 1 };
trace_macros!(false);

This is especially helpful for recursive macros where you want to see each step of the expansion.

Testing macro output

Don't just test that macros compile — test that they produce the right results. Write regular unit tests that invoke the macro and check the output:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn hashmap_macro_creates_correct_map() {
        let map = hashmap! {
            "a" => 1,
            "b" => 2,
        };
        assert_eq!(map.len(), 2);
        assert_eq!(map["a"], 1);
        assert_eq!(map["b"], 2);
    }

    #[test]
    fn hashmap_macro_handles_empty() {
        let map: std::collections::HashMap<&str, i32> = hashmap! {};
        assert!(map.is_empty());
    }

    #[test]
    fn hashmap_macro_trailing_comma() {
        let map = hashmap! { "x" => 1, };
        assert_eq!(map.len(), 1);
    }
}

Test edge cases: empty invocations, trailing commas, single items, expressions with side effects. If your macro does something subtle with evaluation order or hygiene, write a test that catches it.

When to stop

Macros are powerful, but they have real costs. Error messages get worse. Code navigation tools struggle. Your colleagues (and future you) have to mentally expand the macro to understand what it does. Here are the guidelines I use.

If a function works, use a function. This is the most important rule. Generics, trait bounds, and impl Trait solve most problems people reach for macros to handle. Before writing a macro, ask: "Can I do this with generics?" If yes, do that instead. Functions give you type checking, IDE support, and documentation that macros don't.

If your macro is more than about 30 lines, consider a proc macro. Declarative macros are great for small patterns, but they weren't designed for generating large amounts of code or doing complex transformations. Proc macros (derive macros, attribute macros) give you the full power of Rust at compile time. They're harder to set up — they need their own crate — but the code is regular Rust, not pattern-matching soup.

If only you can read it, it's too clever. A macro that saves ten lines but requires five minutes to understand isn't saving anything. The best macros are the ones where the call site is obvious even if you've never seen the definition: hashmap!{ "a" => 1 } is self-explanatory, impl_display!(Foo, Bar, Baz) is self-explanatory. If your macro's call site requires a comment explaining what it does, rethink the design.

Don't use macros for control flow unless it's really worth it. Macros that contain return, break, or continue are surprising. The ensure! pattern is widely understood enough to get a pass, but in general, hidden control flow makes code harder to follow. If someone reading your function can't see the return statements, they can't reason about the function's behavior.

Don't nest macros that generate macros. It's possible. It works. It's also one of the most unreadable things you can do in Rust. If you find yourself writing macro_rules! inside a macro_rules! expansion, step back and find another approach.

Declarative macros are a tool, and like any tool, the skill is knowing when to pick it up and when to leave it on the shelf. For reducing boilerplate across types, for variadic APIs, for small DSLs that make call sites cleaner — reach for macro_rules!. For everything else, a function is almost certainly the right answer.