Skip to main content
Rust · lesson 27 of 30
Rust advanced Lesson 27 of 30

Macros in Rust

Write Rust macros with macro_rules!, build derive and attribute procedural macros, and use serde, thiserror and clap — all compiled and run for real.

By SkillByExample · Updated

Declarative Macros with macro_rules!

Declarative macros match patterns on the input token stream and expand to Rust code:

macro_rules! say_hello {
    () => {
        println!("Hello!");
    };
    ($name:expr) => {
        println!("Hello, {}!", $name);
    };
}

fn main() {
    say_hello!();           // Hello!
    say_hello!("Alice");    // Hello, Alice!
    say_hello!("Bob");      // Hello, Bob!
}
$ rustc macros_declarative_macros_with_macro_rules.rs && ./macros_declarative_macros_with_macro_rules
Hello!
Hello, Alice!
Hello, Bob!

Metavariable Types

DesignatorMatches
exprAn expression
identAn identifier or keyword
tyA type
patA pattern
stmtA statement
blockA block { ... }
itemA top-level item (fn, struct, etc.)
literalA literal value
ttA single token tree
pathA path like std::io::Write

Variadic Macros with Repetition

$(...)* matches zero or more; $(...)+ matches one or more:

macro_rules! max {
    ($x:expr) => { $x };
    ($x:expr, $($rest:expr),+) => {
        {
            let rest_max = max!($($rest),+);
            if $x > rest_max { $x } else { rest_max }
        }
    };
}

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

fn main() {
    println!("{}", max!(3, 1, 4, 1, 5, 9, 2, 6));

    let map = hashmap! {
        "one"   => 1,
        "two"   => 2,
        "three" => 3,
    };
    println!("{}", map["two"]);
}
$ rustc macros_variadic_macros_with_repetition.rs && ./macros_variadic_macros_with_repetition
9
2

A Practical assert_approx_eq! Macro

macro_rules! assert_approx_eq {
    ($a:expr, $b:expr) => {
        assert_approx_eq!($a, $b, 1e-9)
    };
    ($a:expr, $b:expr, $eps:expr) => {
        // the f64 annotation matters: without it `(a - b).abs()` is ambiguous
        let (a, b, eps): (f64, f64, f64) = ($a, $b, $eps);
        assert!(
            (a - b).abs() < eps,
            "assertion failed: |{} - {}| = {} >= {}",
            a, b, (a - b).abs(), eps
        );
    };
}

fn main() {
    println!("0.1 + 0.2 == 0.3? {}", 0.1 + 0.2 == 0.3);
    assert_approx_eq!(0.1 + 0.2, 0.3);
    assert_approx_eq!(1.0, 1.0001, 0.001);
    println!("all assertions passed");
    assert_approx_eq!(1.0, 1.1, 0.01);
}
$ rustc macros_a_practical_assert_approx_eq_macro.rs && ./macros_a_practical_assert_approx_eq_macro
0.1 + 0.2 == 0.3? false
all assertions passed

thread 'main' (20536) panicked at macros_a_practical_assert_approx_eq_macro.rs:21:5:
assertion failed: |1 - 1.1| = 0.10000000000000009 >= 0.01
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

== says 0.1 + 0.2 is not 0.3, but it is within 1e-9, so the two-argument form passes; the second call uses the three-argument arm with a looser tolerance. The last call fails, and the message is the macro’s own format string with the real values substituted. (The number in thread 'main' (N) is the OS thread ID; it varies from run to run.)

Without the (f64, f64, f64) annotation this macro does not compile at all: the literals 0.1, 0.3 are an ambiguous {float} until something pins the type, and the compiler refuses to call .abs() on it (error[E0689]: can't call method 'abs' on ambiguous numeric type '{float}').

Generating Boilerplate with Macros

macro_rules! impl_from_str_for_enum {
    ($enum:ident, $($variant:ident => $s:literal),+) => {
        impl std::str::FromStr for $enum {
            type Err = String;

            fn from_str(s: &str) -> Result<Self, String> {
                match s {
                    $($s => Ok($enum::$variant),)+
                    _ => Err(format!("unknown {}: {}", stringify!($enum), s)),
                }
            }
        }

        impl std::fmt::Display for $enum {
            fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
                match self {
                    $($enum::$variant => write!(f, $s),)+
                }
            }
        }
    };
}

#[derive(Debug, PartialEq)]
enum Color { Red, Green, Blue }

impl_from_str_for_enum!(Color,
    Red   => "red",
    Green => "green",
    Blue  => "blue"
);

fn main() {
    let c: Color = "green".parse().unwrap();
    println!("{:?}", c);
    println!("{}", Color::Blue); // blue
}
$ rustc macros_generating_boilerplate_with_macros.rs && ./macros_generating_boilerplate_with_macros
Green
blue

Procedural Macros

Procedural macros are Rust functions that transform token streams. They live in their own crate with proc-macro = true.

Cargo.toml for the macro crate:

[lib]
proc-macro = true

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

Custom Derive Macro

// In the proc-macro crate: my_macros/src/lib.rs
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput};

#[proc_macro_derive(Describe)]
pub fn describe_derive(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);
    let name = &input.ident;

    let expanded = quote! {
        impl #name {
            pub fn describe() -> &'static str {
                concat!("I am a ", stringify!(#name))
            }
        }
    };

    TokenStream::from(expanded)
}

Usage in the main crate:

use my_macros::Describe;

#[derive(Describe)]
struct Robot;

#[derive(Describe)]
struct Human;

fn main() {
    println!("{}", Robot::describe());
    println!("{}", Human::describe());
}

With my_macros added as a path dependency (my_macros = { path = "../my_macros" }):

$ cargo run -q
I am a Robot
I am a Human

The derive ran at compile time and wrote an impl Robot { fn describe() ... } block for each type; stringify!(#name) turned the type’s identifier into the string literal.

Attribute Macros

An attribute macro receives the item it is attached to and returns a replacement. This one keeps the function’s signature and wraps its body with trace lines:

// proc-macro crate (same lib.rs as above, plus `use syn::ItemFn;`)
#[proc_macro_attribute]
pub fn log_call(_attr: TokenStream, item: TokenStream) -> TokenStream {
    let input = parse_macro_input!(item as ItemFn);
    let vis = &input.vis;
    let sig = &input.sig;
    let body = &input.block;
    let name = sig.ident.to_string();

    // Re-emit the same signature, wrapping the original body
    let expanded = quote! {
        #vis #sig {
            println!("-> {}", #name);
            let result = (|| #body)();
            println!("<- {}", #name);
            result
        }
    };

    TokenStream::from(expanded)
}
use my_macros::log_call;

#[log_call]
fn add(a: i32, b: i32) -> i32 {
    println!("   adding {} + {}", a, b);
    a + b
}

fn main() {
    let total = add(2, 3);
    println!("total = {}", total);
}
$ cargo run -q
-> add
   adding 2 + 3
<- add
total = 5

The original body runs inside a closure so that its return value can be captured and returned after the exit line prints. add’s callers never see the change — its signature is untouched.

derive Macros from the Ecosystem

serde — Serialization

[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize, Debug)]
struct User {
    name: String,
    age: u32,
    #[serde(skip_serializing_if = "Option::is_none")]
    email: Option<String>,
    #[serde(rename = "createdAt")]
    created_at: String,
}

fn main() {
    let user = User {
        name: "Alice".into(),
        age: 30,
        email: None,
        created_at: "2024-01-01".into(),
    };

    let json = serde_json::to_string_pretty(&user).unwrap();
    println!("{}", json);

    let back: User = serde_json::from_str(&json).unwrap();
    println!("{:?}", back);
}
$ cargo run -q
{
  "name": "Alice",
  "age": 30,
  "createdAt": "2024-01-01"
}
User { name: "Alice", age: 30, email: None, created_at: "2024-01-01" }

Both field attributes show up in the JSON: email is omitted because it is None, and created_at is written as createdAt. Reading it back still works — a missing Option field deserializes to None.

thiserror — Error Types

[dependencies]
thiserror = "1"
use thiserror::Error;

#[derive(Debug, Error)]
enum AppError {
    #[error("not found: {0}")]
    NotFound(String),

    #[error("invalid input: {0}")]
    InvalidInput(String),

    #[error(transparent)]
    Io(#[from] std::io::Error),
}

fn load(name: &str) -> Result<String, AppError> {
    if name.is_empty() {
        return Err(AppError::InvalidInput("empty name".into()));
    }
    if name == "ghost" {
        return Err(AppError::NotFound(name.into()));
    }
    Ok(std::fs::read_to_string(name)?)
}

fn main() {
    for name in ["", "ghost", "missing.txt"] {
        println!("{:?} -> {}", name, load(name).unwrap_err());
    }
}
$ cargo run -q
"" -> invalid input: empty name
"ghost" -> not found: ghost
"missing.txt" -> No such file or directory (os error 2)

#[error(transparent)] forwards Display straight to the wrapped io::Error, so the third line is the OS message with no prefix of its own.

clap — CLI Parsing

[dependencies]
clap = { version = "4", features = ["derive"] }
use clap::Parser;

#[derive(Parser, Debug)]
#[command(name = "my-tool", about = "A CLI tool")]
struct Args {
    /// Who to greet
    #[arg(short, long)]
    name: String,

    /// How many times to greet
    #[arg(short, long, default_value = "1")]
    count: u32,

    /// Print the parsed arguments first
    #[arg(long)]
    verbose: bool,
}

fn main() {
    let args = Args::parse();
    if args.verbose {
        println!("{:?}", args);
    }
    for _ in 0..args.count {
        println!("Hello, {}!", args.name);
    }
}

In a package named my-tool:

$ cargo run -q -- --name Ada -c 2 --verbose
Args { name: "Ada", count: 2, verbose: true }
Hello, Ada!
Hello, Ada!
$ cargo run -q -- --help
A CLI tool

Usage: my-tool [OPTIONS] --name <NAME>

Options:
  -n, --name <NAME>    Who to greet
  -c, --count <COUNT>  How many times to greet [default: 1]
      --verbose        Print the parsed arguments first
  -h, --help           Print help
$ cargo run -q -- --count 3
error: the following required arguments were not provided:
  --name <NAME>

Usage: my-tool --name <NAME> --count <COUNT>

For more information, try '--help'.

The /// doc comments became the help text, short derived -n/-c from the field names, and a String field with no default is required — the last run exits with status 2 before main’s loop ever runs. The Usage: line takes the program name from how it was launched (the binary is my-tool because the package is), not from #[command(name)].

Built-in Macros Reference

fn main() {
    // Formatting
    println!("[{}]", format!("{:>10}", "right")); // right-aligned in 10 columns
    println!("[{}]", format!("{:0>5}", 42));      // zero-padded

    // Compile-time information
    println!("{}:{}:{}", file!(), line!(), column!());
    println!("{}", stringify!(1 + 2 * x));        // tokens to a string, unevaluated
    println!("{}", concat!("v", 1, ".", 2));      // literals joined at compile time
    println!("{:?}", option_env!("NO_SUCH_VAR")); // env var read at *compile* time

    // Debug helpers — dbg! prints to stderr and returns its value
    let y = dbg!(1 + 2) * 10;
    println!("y = {}", y);
}
$ rustc macros_built_in_macros_reference.rs && ./macros_built_in_macros_reference
[     right]
[00042]
macros_built_in_macros_reference.rs:7:44
1 + 2 * x
v1.2
None
[macros_built_in_macros_reference.rs:13:13] 1 + 2 = 3
y = 30

dbg! prints the file, line, the expression’s source text and its value, then hands the value back — so it can be dropped into the middle of an expression. env!("CARGO_PKG_NAME") works the same way as option_env! but fails to compile if the variable is missing; Cargo sets CARGO_PKG_NAME for every build, plain rustc does not. include_str!("data.txt") and include_bytes! embed a file’s contents into the binary at compile time.

The panicking macros — panic!, todo!, unimplemented!, unreachable! — all compile to a panic; they differ only in the message and the intent they document:

fn parse_flag(s: &str) -> bool {
    match s {
        "on" => true,
        "off" => false,
        _ => todo!("handle {:?}", s),
    }
}

fn main() {
    println!("{}", parse_flag("on"));
    println!("{}", parse_flag("maybe"));
}
$ rustc macros_built_in_macros_reference_2.rs && ./macros_built_in_macros_reference_2
true

thread 'main' (9600) panicked at macros_built_in_macros_reference_2.rs:5:14:
not yet implemented: handle "maybe"
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

todo! type-checks as any type — here bool — which is what lets you leave an arm unfinished and still compile. As before, the number in thread 'main' (N) is the OS thread ID and varies per run.

Practice

  1. Write a strings! macro that turns any list of Display values into a Vec<String>, and allows a trailing comma: strings!["a", 'b', 3, 4.5,].

    Solution
    macro_rules! strings {
        ($($s:expr),* $(,)?) => {
            vec![$($s.to_string()),*]
        };
    }

    It produces ["a", "b", "3", "4.5"]. $(,)? matches zero or one trailing comma; the $(...),* in the body repeats once per captured $s.

  2. Write a map! macro so that map! { "alice" => 30, "bob" => 25 } builds a BTreeMap.

    Solution
    macro_rules! map {
        ($($k:expr => $v:expr),* $(,)?) => {{
            let mut m = BTreeMap::new();
            $( m.insert($k, $v); )*
            m
        }};
    }

    Prints {"alice": 30, "bob": 25}. The double braces make the expansion a block expression, so the let and the inserts stay local and the block evaluates to m.

  3. Call assert_approx_eq!(1, 1.0). The macro text is pasted in before type checking — so where does the compiler point?

    Solution
    error[E0308]: mismatched types
      --> macros_badarg.rs:12:23
       |
    12 |     assert_approx_eq!(1, 1.0);
       |                       ^ expected `f64`, found integer

    It points at the 1 at the call site, not inside the macro — the compiler tracks where each token came from. The expected f64 comes from the (f64, f64, f64) annotation in the expansion.

Frequently Asked Questions

What is the difference between declarative and procedural macros?
Declarative macros (macro_rules!) use pattern matching on token trees and are simpler to write. Procedural macros operate on the AST as Rust code and are more powerful but require a separate crate.
When should I write a macro instead of a function?
Use a macro when you need variadic arguments, when you need to generate code at compile time, or when you need to work with syntax (like identifiers or types) that functions cannot accept.
What crates help write procedural macros?
syn parses Rust token streams into an AST. quote turns Rust code back into token streams. proc-macro2 provides stable versions of the proc_macro types.