Let’s talk about a tool in Rust that often seems mysterious but is incredibly practical: procedural macros. Think of them as small programs that run while your code is being compiled. Their job is to write more Rust code for you. I use them to remove repetitive work, enforce rules, and make my code safer and clearer. If you’ve ever typed the same pattern over and over, a procedural macro might be the solution.
Today, I’ll walk you through eight concrete ways I apply them. I’ll show you the code and explain what happens, step by step. This isn’t about complex theory; it’s about solving everyday coding problems.
First, let’s address the automatic implementation of traits. You know traits like Debug or Clone. You add #[derive(Debug)] above a struct, and Rust automatically writes the fmt method for you. What if you have your own trait? Writing the implementation for dozens of structs is tedious and error-prone.
This is where a custom derive macro comes in. Let’s say I have a Printable trait. I want every struct that uses it to automatically produce a simple description. Instead of implementing it manually for Point, User, Transaction, I write a macro once.
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput};
#[proc_macro_derive(Printable)]
pub fn printable_derive(input: TokenStream) -> TokenStream {
// Parse the Rust code we've attached the derive to
let ast = parse_macro_input!(input as DeriveInput);
// Get the name of the struct or enum
let struct_name = &ast.ident;
// This is the new Rust code we're generating
let generated_code = quote! {
impl Printable for #struct_name {
fn print(&self) -> String {
format!("This is a {}", stringify!(#struct_name))
}
}
};
// Send this new code back to the compiler
generated_code.into()
}
Now, using it is beautifully simple.
// In my main project
use my_macros::Printable;
#[derive(Printable)]
struct Point {
x: i32,
y: i32,
}
fn main() {
let p = Point { x: 5, y: 10 };
println!("{}", p.print()); // Prints: "This is a Point"
}
The macro sees struct Point, grabs its name, and injects a complete impl Printable for Point block. I never have to write that impl myself. This pattern is perfect for serialization, comparisons, or any trait that follows a standard formula based on a type’s structure.
Another common task is adding behavior to functions without changing their core logic. Imagine you need to log how long every function in a critical module takes. You could add timing code to the start and end of each one. But that mixes performance tracking with business logic, and it’s easy to forget.
An attribute macro can wrap a function for you. You attach it like a decorator, and it modifies the function’s code.
#[proc_macro_attribute]
pub fn log_duration(_attr: TokenStream, item: TokenStream) -> TokenStream {
// Parse the function we're attaching this attribute to
let input_function = parse_macro_input!(item as syn::ItemFn);
let function_name = &input_function.sig.ident;
let function_body = &input_function.block;
let generated_code = quote! {
// We're redefining the function with new code around it
fn #function_name() {
let start_time = std::time::Instant::now();
// This inserts the original function body here
#function_body
let elapsed_time = start_time.elapsed();
println!("Function '{}' completed in {:?}", stringify!(#function_name), elapsed_time);
}
};
generated_code.into()
}
Using it feels clean and declarative.
#[log_duration]
fn fetch_data_from_api() {
// Simulate a network request
std::thread::sleep(std::time::Duration::from_millis(150));
println!("Data fetched.");
}
fn main() {
fetch_data_from_api();
// Console output:
// Data fetched.
// Function 'fetch_data_from_api' completed in 150.12ms
}
The original fetch_data_from_api function stays focused on its job. The macro handles the cross-cutting concern of timing. I use this for authorization checks, input sanitization, or audit logging. It keeps my functions clean and my behavior consistent.
Sometimes, the built-in Rust syntax isn’t the most expressive for a specific task. You might want a mini-language for defining lists, queries, or states. Function-like macros let you create your own syntax that expands into valid Rust.
For example, creating a vector of strings from a comma-separated list. The standard way is a bit verbose: vec!["apple".to_string(), "banana".to_string()]. What if I want something quicker for configuration?
#[proc_macro]
pub fn vec_str(input: TokenStream) -> TokenStream {
// Expect a string literal like "a, b, c"
let input_string = parse_macro_input!(input as syn::LitStr);
let string_value = input_string.value();
// Split on commas and trim whitespace
let items: Vec<&str> = string_value.split(',').map(|s| s.trim()).collect();
let generated_code = quote! {
{
let mut temp_vec = Vec::new();
// This iterates and pushes each item
#(temp_vec.push(#items.to_string());)*
temp_vec
}
};
generated_code.into()
}
Now I have a clear, purpose-built syntax.
let fruit_basket = vec_str!("apple, banana, cherry, dragon fruit");
let ports = vec_str!("8080, 443, 3000");
The macro validates the basic format at compile time (it must be a string literal) and generates the efficient Vec construction code. I’ve used similar patterns for defining routing tables, state machines, or test data. It makes the code’s intent immediately obvious.
Building complex objects with many optional fields often leads to the builder pattern. You create a separate builder struct with setter methods, culminating in a build() call. Writing this builder by hand for every configuration struct is a lot of boilerplate.
A derive macro can generate a fluent builder automatically. Let’s look at a simplified version.
#[proc_macro_derive(Builder)]
pub fn builder_derive(input: TokenStream) -> TokenStream {
let ast = parse_macro_input!(input as DeriveInput);
let original_struct_name = &ast.ident;
// Create a name for the builder: "ServerConfig" -> "ServerConfigBuilder"
let builder_name_string = format!("{}Builder", original_struct_name);
let builder_struct_name = syn::Ident::new(&builder_name_string, original_struct_name.span());
// In a real macro, you would iterate over the struct's fields here
// and generate a builder field and setter method for each one.
// This is a minimal example to show the concept.
let generated_code = quote! {
// 1. Define the builder struct
pub struct #builder_struct_name {
// Fields would be generated here, often as Options
}
// 2. Implement methods on the builder
impl #builder_struct_name {
pub fn build(self) -> Result<#original_struct_name, String> {
// Here, you would check if all required fields are set
Ok(#original_struct_name { /* constructed fields */ })
}
// Setter methods would be generated here
}
// 3. Add a convenience method to the original struct
impl #original_struct_name {
pub fn builder() -> #builder_struct_name {
#builder_struct_name { /* default fields */ }
}
}
};
generated_code.into()
}
Its usage provides a type-safe, clear API.
#[derive(Builder)]
struct ServerConfig {
host: String,
port: u16,
timeout: u64,
}
fn main() {
let config = ServerConfig::builder()
.host("localhost".to_string())
.port(8080)
.timeout(30)
.build()
.unwrap();
}
The macro writes all the repetitive setter method code. It ensures you can’t call build() until you’ve provided necessary values. This eliminates a whole class of runtime errors related to incomplete configuration.
Catching errors as early as possible is a Rust philosophy. Runtime validation is good, but compile-time validation is better. If a constant value is wrong, why wait for the program to start to find out?
A function-like macro can evaluate constant expressions and panic during compilation if they’re invalid.
#[proc_macro]
pub fn validated_port(input: TokenStream) -> TokenStream {
let port_literal = parse_macro_input!(input as syn::LitInt);
let port_value: u16 = port_literal.base10_parse().expect("Must be a number");
// Compile-time check!
if port_value == 0 {
panic!("Port cannot be 0");
}
if port_value < 1024 {
panic!("Ports below 1024 are system ports. Use a port >= 1024.");
}
// If checks pass, output the number as a literal token
quote! { #port_value }.into()
}
This turns a potential deployment issue into a fast feedback loop for the developer.
const WEB_PORT: u16 = validated_port!(8080); // OK
const SYSTEM_PORT: u16 = validated_port!(80); // Compilation Error: Ports below 1024 are system ports...
The compiler stops you immediately with your custom error message. I use this for validating configuration thresholds, magic numbers, or any invariant that can be checked with constant data. It turns your build process into a powerful validation stage.
Enums are powerful, but they often require small helper methods. Is this State variant a Success? Is that Error variant a NetworkError? Writing is_success or is_network_error methods for large enums is manual work.
A derive macro can generate these predicate methods automatically by inspecting the enum’s variants.
#[proc_macro_derive(EnumHelpers)]
pub fn enum_helpers_derive(input: TokenStream) -> TokenStream {
let ast = parse_macro_input!(input as DeriveInput);
let enum_name = &ast.ident;
if let syn::Data::Enum(enum_data) = &ast.data {
let mut method_definitions = Vec::new();
for variant in &enum_data.variants {
let variant_name = &variant.ident;
// Create a method name like `is_success` from variant `Success`
let method_name_string = format!("is_{}", variant_name.to_string().to_lowercase());
let method_name = syn::Ident::new(&method_name_string, variant_name.span());
let method_code = quote! {
pub fn #method_name(&self) -> bool {
matches!(self, #enum_name::#variant_name { .. })
}
};
method_definitions.push(method_code);
}
let generated_code = quote! {
impl #enum_name {
#(#method_definitions)*
}
};
generated_code.into()
} else {
// Panic if this derive is placed on a struct or union
panic!("`EnumHelpers` can only be used with enums.");
}
}
The generated methods make code much more readable.
#[derive(EnumHelpers)]
enum ConnectionState {
Connected,
Connecting,
Disconnected,
Error(String),
}
fn main() {
let state = ConnectionState::Connected;
if state.is_connected() { // Generated method!
println!("All good.");
}
let error_state = ConnectionState::Error("Timeout".to_string());
if error_state.is_error() { // Generated method!
println!("There was a problem.");
}
}
This removes the need for verbose match statements just to check a variant and keeps the code intention clear.
A classic Rust technique for type safety is the newtype pattern: wrapping a primitive, like a u64, in a tuple struct to give it a distinct type. UserId(u64) and ProductId(u64) are different types, preventing you from passing one where the other is expected. However, you then need to manually derive common traits and write accessors.
An attribute macro can apply this pattern consistently.
#[proc_macro_attribute]
pub fn typed_id(_attr: TokenStream, item: TokenStream) -> TokenStream {
let input_struct = parse_macro_input!(item as syn::ItemStruct);
let struct_name = &input_struct.ident;
let generated_code = quote! {
// The original struct definition, plus automatic trait derives
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
#input_struct
impl #struct_name {
// A constructor
pub fn new(value: u64) -> Self {
Self(value)
}
// A getter
pub fn get(&self) -> u64 {
self.0
}
}
// Make it display nicely
impl std::fmt::Display for #struct_name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}[{}]", stringify!(#struct_name), self.0)
}
}
// Optional: Implement From<u64> for convenience
impl From<u64> for #struct_name {
fn from(value: u64) -> Self {
Self::new(value)
}
}
};
generated_code.into()
}
Now creating a strong type is a one-line affair.
#[typed_id]
struct UserId(u64);
#[typed_id]
struct ProductId(u64);
fn process_user(id: UserId) {
println!("Processing user: {}", id); // Uses the Display impl
}
fn main() {
let user = UserId::new(42);
let product = ProductId::from(1001);
process_user(user);
// process_user(product); // COMPILE ERROR: expected `UserId`, found `ProductId`
}
The macro ensures all these related types get the same boilerplate, promoting consistency and eliminating a tedious task.
Finally, testing. Good tests often mean running the same assertion logic over many different inputs. Writing a separate test function for each case creates clutter.
A macro can generate multiple test functions from a list of inputs or a data structure.
#[proc_macro]
pub fn test_cases(input: TokenStream) -> TokenStream {
let input_array = parse_macro_input!(input as syn::ExprArray);
let mut test_functions = Vec::new();
for (index, test_case_expression) in input_array.elems.iter().enumerate() {
let test_name = syn::Ident::new(&format!("test_case_{}", index), proc_macro2::Span::call_site());
let test_code = quote! {
#[test]
fn #test_name() {
// Use the provided expression as the test input
let input = #test_case_expression;
// The common test logic
let result = input * 2;
assert_eq!(result, input + input, "Doubling failed for input: {}", input);
}
};
test_functions.push(test_code);
}
quote! { #(#test_functions)* }.into()
}
This condenses a test module significantly.
// Instead of:
// #[test] fn test_double_1() { ... }
// #[test] fn test_double_2() { ... }
// #[test] fn test_double_3() { ... }
// You write:
mod arithmetic_tests {
test_cases!([1, 2, 5, -3, 1000]);
}
// The macro expands to five separate test functions.
When you run cargo test, you’ll see test_case_0, test_case_1, etc., each testing a different input. This is incredibly useful for property-based testing or ensuring a function works across a known set of edge cases. It keeps the test data easy to read and modify.
These are eight paths into the world of procedural macros. They start by automating the obvious, repetitive tasks. Over time, you begin to see patterns in your code that are begging to be abstracted. The compiler becomes your partner, not just a translator, helping you enforce rules and generate robust code. It takes practice, but the payoff in clean, safe, and expressive code is immense. Start with a simple derive macro for a trait you use often. You might be surprised how quickly it changes your approach to structuring projects.