Skip to content

Chapter 18

Macros - Reducing Boilerplate

You've been using macros since line one: println!, vec!, format!. Now let's write our own to eliminate repetitive patterns in our server code.

Declarative Macros with macro_rules!

A declarative macro (also called "macros by example") works by pattern matching on the code you pass in. You define patterns, and the compiler replaces matching invocations with the template code you provide. It's code that writes code - at compile time.

// The simplest possible macro
macro_rules! say_hello {
    () => {
        println!("Hello from a macro!");
    };
}

say_hello!(); // expands to: println!("Hello from a macro!");

Let's break down the syntax:

  • macro_rules! name - declares the macro with a name.
  • () => { ... } - a pattern-match arm. The left side matches what you write inside say_hello!(...). The right side is the code it expands to.
  • Macros are invoked with ! - this distinguishes them from function calls.

Capturing Arguments

Macros capture input with $name:kind where kind is a fragment specifier - the type of syntax to match:

macro_rules! log_request {
    ($method:expr, $path:expr) => {
        println!("[REQUEST] {} {}", $method, $path);
    };
}

log_request!("GET", "/api/todos");
// expands to: println!("[REQUEST] {} {}", "GET", "/api/todos");
SpecifierMatchesExample
exprAny expression42, x + 1, foo()
identAn identifiermy_var, Request
tyA typeu32, String, Vec<u8>
patA patternSome(x), (a, b)
ttA single token treeanything - the most flexible
literalA literal value"hello", 42, true
blockA block of code{ x + 1 }

Multiple Arms

Like match, macros can have multiple arms. The first matching pattern wins:

macro_rules! response {
    // response!(200)
    ($status:expr) => {
        Response::new($status, status_reason($status), "")
    };
    // response!(200, "OK body")
    ($status:expr, $body:expr) => {
        Response::new($status, status_reason($status), $body)
    };
    // response!(200, "OK", "custom body")
    ($status:expr, $reason:expr, $body:expr) => {
        Response::new($status, $reason, $body)
    };
}

fn status_reason(code: u16) -> &'static str {
    match code {
        200 => "OK", 201 => "Created", 204 => "No Content",
        400 => "Bad Request", 404 => "Not Found",
        500 => "Internal Server Error", _ => "Unknown",
    }
}

let r1 = response!(404);                      // no body
let r2 = response!(200, "<h1>Hello</h1>");    // with body
let r3 = response!(200, "OK", "custom");       // full control

Repetition

Macros handle variable numbers of arguments with repetition syntax:$( ... ),* matches zero or more comma-separated items.

/// Create a HashMap from key-value pairs.
macro_rules! map {
    ( $( $key:expr => $value:expr ),* $(,)? ) => {{
        let mut m = std::collections::HashMap::new();
        $( m.insert($key, $value); )*
        m
    }};
}

let headers = map! {
    "Content-Type" => "text/html",
    "Server" => "rust-webserver",
    "X-Powered-By" => "macros",
};
// Expands to:
// let mut m = HashMap::new();
// m.insert("Content-Type", "text/html");
// m.insert("Server", "rust-webserver");
// m.insert("X-Powered-By", "macros");
// m

The $(...),* in the pattern matches each pair. The $(...)* in the expansion repeats for each match. The $(,)? at the end allows an optional trailing comma.

Note
This is exactly how vec![] is implemented in the standard library. vec![1, 2, 3] expands to a series of push calls on a pre-allocated Vec.

Writing a route! Macro for Cleaner Routing

Our router setup has repetitive boilerplate. Let's replace it with a macro:

// Before: verbose and repetitive
fn build_router() -> Router {
    let mut router = Router::new();
    router.add(Method::Get, "/", home);
    router.add(Method::Get, "/about", about);
    router.add(Method::Get, "/api/todos", list_todos);
    router.add(Method::Post, "/api/todos", create_todo);
    router.add(Method::Get, "/stats", stats_page);
    router
}
// The macro
macro_rules! routes {
    ( $( $method:ident $path:literal => $handler:expr ),* $(,)? ) => {{
        let mut router = Router::new();
        $(
            router.add(Method::$method, $path, $handler);
        )*
        router
    }};
}

// After: concise and scannable
fn build_router() -> Router {
    routes! {
        Get    "/"           => home,
        Get    "/about"      => about,
        Get    "/api/todos"  => list_todos,
        Post   "/api/todos"  => create_todo,
        Get    "/stats"      => stats_page,
    }
}

The improvement: every route is one line with a consistent format - method, path, handler. No noise from router.add, Method::, or semicolons. The macro handles it all.

Let's go further with a macro that also defines inline handlers:

macro_rules! routes {
    ( $( $method:ident $path:literal => $body:block ),* $(,)? ) => {{
        let mut router = Router::new();
        $(
            router.add(Method::$method, $path, |_req: &Request| -> Result<Response, ServerError> {
                $body
            });
        )*
        router
    }};
}

fn build_router() -> Router {
    routes! {
        Get "/" => {
            Ok(Response::html("<h1>Welcome!</h1>"))
        },
        Get "/about" => {
            Ok(Response::html("<h1>About</h1>"))
        },
        Get "/health" => {
            Ok(Response::json_ok(&serde_json::json!({"status": "ok"})))
        },
    }
}
Tip
Use cargo expand (install with cargo install cargo-expand) to see what your macro expands to. It's invaluable for debugging: cargo expand --bin webserver shows all macros fully expanded.

When to Use Macros vs Generics vs Traits

Macros, generics, and traits all reduce code duplication, but in different ways. Choosing the right tool matters for readability and maintainability:

Use Generics when...

You want the same logic for different types. The compiler generates specialized code for each concrete type. Type-safe, inspectable, and the IDE understands it.

// Good use of generics
fn send<T: ToHttp>(response: &T, stream: &mut TcpStream) {
    stream.write_all(&response.to_http()).unwrap();
}

Use Traits when...

You want different types to share a common interface. Types provide their own implementation. Best for polymorphism and abstraction.

// Good use of traits
trait Handler {
    fn handle(&self, req: &Request) -> Result<Response, ServerError>;
}

Use Macros when...

You need to generate code, not abstract over types. Macros can do things generics and traits can't:

  • Variable numbers of arguments (vec![1, 2, 3])
  • Generating new identifiers or types
  • DSLs with custom syntax (routes!)
  • Conditional compilation
  • Repeating a pattern with different names
Warning
Macros are powerful but harder to debug, harder to read, and invisible to IDEs (autocompletion, go-to-definition). Use them as a last resort - if a function, generic, or trait can do the job, prefer that. The test: if you can write a function that does the same thing, you probably should.
FeatureGenericsTraitsMacros
Type checkingCompile timeCompile timeAfter expansion
IDE supportFullFullLimited
Variadic argsNoNoYes
Generate codeNoNoYes
Custom syntaxNoNoYes
Error messagesClearClearConfusing

A Brief Look at Procedural Macros and Derive Macros

macro_rules! macros work by pattern matching on tokens. Procedural macros are more powerful - they're Rust functions that receive a token stream and return a modified token stream. They can inspect and transform code arbitrarily.

You've been using procedural macros throughout this guide:

// Derive macros - generate trait implementations
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Todo {
    id: u64,
    title: String,
    completed: bool,
}

// Attribute macros - transform the annotated item
#[tokio::main]
async fn main() { /* ... */ }

// #[tokio::main] transforms this into:
// fn main() {
//     tokio::runtime::Runtime::new().unwrap()
//         .block_on(async { /* ... */ });
// }

There are three kinds of procedural macros:

Derive macros - #[derive(MyTrait)]

Generate trait implementations from struct/enum definitions. Used by serde, Debug, Clone.

Attribute macros - #[my_attribute]

Transform the item they're attached to. Used by #[tokio::main], #[test], #[route("GET", "/")] in web frameworks.

Function-like macros - my_macro!(...)

Look like macro_rules! macros but are implemented as Rust functions. Used by serde_json::json!, sqlx::query!.

Writing procedural macros requires a separate crate (they must be compiled before the code that uses them). Here's what a derive macro looks like at a high level:

my_macro/src/lib.rs
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput};

/// Derive macro that implements Display for a struct
/// by printing its name and fields.
#[proc_macro_derive(AutoDisplay)]
pub fn auto_display(input: TokenStream) -> TokenStream {
    let ast = parse_macro_input!(input as DeriveInput);
    let name = &ast.ident;

    let expanded = quote! {
        impl std::fmt::Display for #name {
            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
                write!(f, "{}", stringify!(#name))
            }
        }
    };

    expanded.into()
}

// Usage:
// #[derive(AutoDisplay)]
// struct MyStruct { ... }
// println!("{}", my_struct); // prints "MyStruct"

The key crates for procedural macros:

  • syn - parses Rust source code into an AST (abstract syntax tree) you can inspect.
  • quote - generates Rust source code from templates with quote!.
  • proc_macro - the compiler interface (provided by Rust itself).
Note
You don't need to write procedural macros for most projects. They're for library authors building frameworks and DSLs. As a user, you just #[derive(Serialize)] and move on. Understanding that they exist and how they work is enough - you'll know when you need one.

Useful Macros You Should Know

The standard library and common crates provide macros worth memorizing:

// todo!() - marks unfinished code, compiles but panics at runtime
fn handle_put(req: &Request) -> Response {
    todo!("implement PUT handler")
}

// unimplemented!() - like todo but for things you may never implement
fn handle_trace(req: &Request) -> Response {
    unimplemented!("TRACE is not supported")
}

// unreachable!() - asserts this code path is impossible
fn status_reason(code: u16) -> &'static str {
    match code {
        200 => "OK",
        404 => "Not Found",
        _ => unreachable!("unexpected status code: {}", code),
    }
}

// dbg!() - prints expression + value to stderr, returns the value
let port = dbg!(8080 + 1); // [src/main.rs:42] 8080 + 1 = 8081
let len = dbg!(body.len()); // prints and returns the length

// include_str!() / include_bytes!() - embed files at compile time
const INDEX_HTML: &str = include_str!("../public/index.html");
const FAVICON: &[u8] = include_bytes!("../public/favicon.ico");

// cfg!() - compile-time feature/platform check
if cfg!(debug_assertions) {
    println!("Debug mode - extra logging enabled");
}

// concat!() - concatenate string literals at compile time
const VERSION: &str = concat!("webserver/", env!("CARGO_PKG_VERSION"));
// "webserver/0.1.0"
Tip
include_str! and include_bytes! are powerful for small servers - you can embed your HTML templates, favicon, and CSS directly into the binary. No file I/O at runtime, no missing files in deployment. The tradeoff is binary size and needing to recompile when assets change.

Exercise

  1. Write a headers! macro that creates a Vec<(String, String)> from pairs: headers!("Content-Type" => "text/html", "Server" => "rust").
  2. Extend the routes! macro to support an optional third field for middleware: Get "/admin" => admin_page [auth_middleware].
  3. Write a json_response! macro that takes a status code and a JSON-like body: json_response!(200, {"status": "ok"}). Hint: use serde_json::json! internally.
  4. Use include_str! to embed your public/index.html into the binary. Serve it from the embedded string instead of reading from disk. Compare: what are the tradeoffs?
  5. Install cargo-expand and run cargo expand on your project. Find the expanded #[derive(Debug)] for one of your structs. How much code does the derive macro generate?

What's Next

Macros are a sharp tool - powerful for eliminating boilerplate, but worth reaching for only when functions, generics, and traits aren't enough. You now know declarative macros with macro_rules!, the three kinds of procedural macros, and when to use each approach.

In the next chapter, we test our server - unit tests for individual functions, integration tests that hit the HTTP endpoint, and property-based testing to find edge cases we didn't think of.