Rust Macros: 8 Proven Patterns for Cleaner, More Efficient Code Generation
Master Rust macros with 8 proven code generation patterns. From declarative to procedural, learn to eliminate boilerplate and write cleaner, maintainable code. Read now.
I remember the first time I wrote a macro in Rust. I was stuck with a mountain of boilerplate, copy-pasting the same trait implementations across a dozen structs. My fingers ached, my code stank, and every change meant fixing the same pattern in five places. Then someone whispered the word “macro,” and I dived in headfirst. I broke things. I wrote macros that looked like hieroglyphics. But over time I learned eight patterns that turned my messy code generation into something beautiful and reliable. I want to share those patterns with you, as if we’re sitting next to each other, explaining each one in the simplest way possible.
Macros are just functions that run at compile time. They take Rust code as input and spit out new Rust code. There are two kinds: declarative macros (macro_rules!) and procedural macros (custom derives, attributes, and function-like macros). Both serve one purpose: write code that writes code, so you don’t have to. Let’s start with the easiest.
Pattern 1 – Start with declarative macros for simple code generation
If you want to repeat a pattern of syntax – say, implementing a trait for a struct you haven’t written yet – a declarative macro is your best friend. It looks like a match statement but matches against Rust tokens. I use it when I need to generate a bunch of similar functions without thinking about types at runtime.
Here is the simplest example I can think of: making a vector of strings.
macro_rules! vec_of_strings {
( $( $x:expr ),* ) => {
vec![ $( $x.to_string() ),* ]
};
}
fn main() {
let words = vec_of_strings!("hello", "world", "macro");
// This expands to: vec!["hello".to_string(), "world".to_string(), "macro".to_string()]
println!("{:?}", words);
}
The magic is inside the parentheses. $( $x:expr ),* means “match zero or more expressions, separated by commas.” Inside the expansion, we repeat each expression with .to_string() added. I used this recently to convert a list of integer constants into a vector of f64 values in a physics simulation. Saved me fifteen lines of manual conversion.
The rule of thumb is: if you find yourself typing the same code structure three times, write a declarative macro. But keep them short. If the macro body is longer than twenty lines, consider a procedural macro instead.
Pattern 2 – Capture variable-length arguments with repetition operators
Declarative macros can accept any number of arguments. The repetition operators * (zero or more), + (one or more), and ? (zero or one) control how many repetitions you allow. Combined with a separator – usually a comma – you can build functions that look like variadic functions, but happen at compile time.
I once needed a macro that creates a HashMap from a list of key-value pairs. Here’s what I wrote:
macro_rules! make_map {
( $( $key:expr => $value:expr ),+ ) => {
{
let mut map = std::collections::HashMap::new();
$(
map.insert($key, $value);
)+
map
}
};
}
fn main() {
let config = make_map!(
"host" => "localhost",
"port" => 8080,
"timeout" => 30
);
println!("{:?}", config);
}
Notice I used + instead of * because an empty map wouldn’t make sense. Also notice the trailing comma is optional because I wrote only one comma between repetitions. If you want to allow a trailing comma (a common Rust style), you can write $( $key:expr => $value:expr ),* $(,)? – the ? makes an extra comma optional.
The trick is to keep the repetition pattern simple. If you need different separators or nested repetitions, you might overcomplicate the macro. In that case, switch to a procedural macro.
Pattern 3 – Use procedural macros for compile-time code transformation
When a declarative macro can’t do what you need – because you need to inspect types, parse complex syntax, or generate code conditionally – reach for procedural macros. These run on the token stream of your code before compilation. There are three types, but let’s start with the most common: custom derive macros.
A derive macro adds an implementation of a trait to your struct or enum automatically. The built-in Debug, Clone, PartialEq are all implemented with derive macros. You can write your own.
Here is a silly example: a derive that adds a say_hello method that prints the type name.
// In a separate crate called my_macros
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput};
#[proc_macro_derive(Hello)]
pub fn hello_derive(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
let name = &input.ident;
let expanded = quote! {
impl #name {
fn say_hello(&self) {
println!("Hello from {}", stringify!(#name));
}
}
};
expanded.into()
}
And in your main crate:
use my_macros::Hello;
#[derive(Hello)]
struct MyStruct;
fn main() {
MyStruct.say_hello(); // prints "Hello from MyStruct"
}
The first time I wrote a derive macro, I was amazed that I could inspect each field of a struct and generate code based on its type. For example, if a field is an Option, you might skip serialization when it’s None. That is exactly how serde works. Procedural macros give you that power.
Pattern 4 – Create attribute macros to annotate functions or modules
Attribute macros let you wrap a function or module with extra logic. Think of them as “decorators” from other languages, but they run at compile time. I used an attribute macro to add automatic logging around every function in a web server handler.
Here is a simple macro that prints the time a function takes to run:
// In my_macros crate
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, ItemFn};
#[proc_macro_attribute]
pub fn timed(_attr: TokenStream, item: TokenStream) -> TokenStream {
let input_fn = parse_macro_input!(item as ItemFn);
let fn_name = &input_fn.sig.ident;
let block = &input_fn.block;
let expanded = quote! {
fn #fn_name() {
let start = std::time::Instant::now();
#block
println!("{} took {:?}", stringify!(#fn_name), start.elapsed());
}
};
expanded.into()
}
And you use it like this:
use my_macros::timed;
#[timed]
fn expensive() {
std::thread::sleep(std::time::Duration::from_secs(2));
}
But wait – the macro above only works for functions with no arguments. In real code, you need to preserve the function signature. The syn crate can help you parse the full Signature and include it. I’ve written a more complete version that passes through arguments. The point is: attribute macros are perfect for cross-cutting concerns like logging, validation, retry logic, or even memoization – all transformed at compile time.
Pattern 5 – Build function-like procedural macros for expression-level transformations
Sometimes you need a macro that looks like a function call but does compile-time work – like embedding a file into your binary, hashing a string, or building a regular expression. Function-like procedural macros accept a token stream inside parentheses and return a new token stream.
The simplest possible function-like macro is an identity macro that returns its input unchanged:
use proc_macro::TokenStream;
#[proc_macro]
pub fn echo(input: TokenStream) -> TokenStream {
input
}
// Usage:
fn main() {
let x = echo!(42 + 1);
println!("{}", x); // 43
}
That’s boring. A more useful example is a compile-time hash of a string. You can parse the string literal, compute its SHA256, and return a byte array literal. I built one for a password verification library. The macro ensured that the hash was computed once at compile time, not at runtime. Here is a skeleton:
// Pseudocode: full implementation would require sha2 crate
#[proc_macro]
pub fn hash_password(input: TokenStream) -> TokenStream {
let password: syn::LitStr = syn::parse(input).unwrap();
let password_str = password.value();
let hash = compute_sha256(&password_str); // compile-time function
let hash_bytes = hash.as_bytes();
let expanded = quote! { [ #(#hash_bytes),* ] };
expanded.into()
}
Now your program never stores the password in plaintext – only the compiled hash.
Pattern 6 – Generate trait implementations with derive macros
Derive macros are the workhorses of Rust metaprogramming. You see them everywhere: #[derive(Debug, Clone, Serialize)]. You can write your own derive that automatically implements a custom trait for any struct or enum. The key is to use syn’s Data enumeration to inspect the shape of the type.
I once needed a trait ToJson that converts a struct to a JSON-like string. Instead of writing it by hand for each struct, I created a derive macro:
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput, Data, Fields};
#[proc_macro_derive(ToJson)]
pub fn to_json_derive(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
let name = &input.ident;
let fields = match &input.data {
Data::Struct(ds) => &ds.fields,
_ => panic!("ToJson only supports structs"),
};
let field_names: Vec<_> = fields.iter().map(|f| &f.ident).collect();
let field_types: Vec<_> = fields.iter().map(|f| &f.ty).collect();
let expanded = quote! {
impl ToJson for #name {
fn to_json(&self) -> String {
let mut s = String::from("{");
#(
s.push_str(&format!("\"{}\": \"{}\", ", stringify!(#field_names), self.#field_names));
)*
s.push_str("}");
s
}
}
};
expanded.into()
}
The macro iterates over each field and generates a format string that includes the field name and its value. If a field is an integer, the output would still wrap it in quotes, but you can refine it by checking the type – that’s where syn gets really powerful.
Pattern 7 – Combine macros with helper traits for conditional expansion
Declarative macros are dumb: they only see tokens, not types. If you want a macro to behave differently based on whether a type implements a certain trait, you can’t ask the macro to check that directly. The trick is to delegate the conditional logic to a helper trait.
I use this pattern when I want to serialize a collection of values that might be numbers, strings, or custom types. I define a trait SerializeValue and implement it for the types I care about. Then the macro calls .serialize() on each element.
trait SerializeValue {
fn serialize(&self) -> String;
}
impl SerializeValue for i32 {
fn serialize(&self) -> String { format!("{}", self) }
}
impl SerializeValue for String {
fn serialize(&self) -> String { format!("\"{}\"", self) }
}
macro_rules! json_array {
( $( $elem:expr ),* ) => {
vec![ $( $elem.serialize() ),* ].join(", ")
};
}
fn main() {
let arr = json_array!(1, "hello".to_string(), 42);
println!("[{}]", arr); // [1, "hello", 42]
}
Now, if I add a new type, I just implement SerializeValue for it. The macro stays unchanged. This pattern is why serde is so powerful – its Serialize trait handles all the type‑specific logic, and the derive macro just calls it.
Pattern 8 – Test macros thoroughly by inspecting their expansion
Macros generate code that you cannot see unless you expand it. Bugs hide in the generated code. The only way to be sure your macro works is to test the expansion. I learned this the hard way when a macro I wrote silently omitted a semicolon, causing a compilation error only in a specific edge case. Now I always write tests.
For declarative macros, use the macrotest crate. It takes a file of Rust code containing macro invocations and compares the expanded output to a file of expected output. For example:
// tests/macro_expand.rs
#[test]
fn test_vec_of_strings() {
macrotest::expand("tests/expand/*.rs");
}
You place a file tests/expand/vec_of_strings.rs with the macro usage and a corresponding .expanded.rs with the expected result. It’s a bit of work up front, but it catches every regression.
For procedural macros, write unit tests that call the proc macro function directly with a token stream and assert on the result. Here’s a test for our hello derive:
#[test]
fn test_hello_derive() {
use quote::quote;
use my_macros::hello_derive;
let input = quote! {
struct Foo;
};
let result = hello_derive(input.into()).to_string();
let expected = quote! {
impl Foo {
fn say_hello(&self) {
println!("Hello from {}", stringify!(Foo));
}
}
}.to_string();
assert_eq!(result, expected);
}
I keep a folder of such tests in the proc-macro crate itself. Running cargo test then checks that the generated code matches exactly what I intend.
I’ve used these eight patterns in everything from embedded firmware to web APIs. They saved me from typing thousands of lines of repetitive code. The key is knowing when to use which tool:
- Use declarative macros for simple text‑based code generation that doesn’t need type information.
- Use procedural macros when you need to inspect types, generate code conditionally, or create new syntax.
- Always test your macros by expanding them and comparing the output.
Start with the smallest pattern that solves your problem. Don’t reach for a procedural macro if a declarative one works. And never write a macro that produces unreadable code – the person maintaining your code (maybe future you) will thank you.
I hope these patterns help you write Rust that is elegant, efficient, and – most importantly – maintainable. Go write some macros. You’ll wonder how you ever lived without them.