Procedural Macros

31 min read
On this page

Procedural macros use Rust programs to process tokens and output tokens, making them suitable for structured parsing and code generation that declarative macros struggle to express. They run in the host compilation environment, and their output is then compiled by the target crate; they are not general-purpose compiler plugins that can query the real types of arbitrary variables or trait implementations.

Caller's TokenStreamderive input type; attribute input attributes and the annotated item1Parse and check syntax structuresyn provides the AST; no type inference results are available yet2Generate TokenStreamquote: preserve generics, bounds and spans3Caller continues compilation4Procedural macros run on the host, and the output code enters the target crateGenerated items undergo name, type and borrow checkingThe macro execution environment and the target platform of the final program may differ.

How the Three Types of Macros Change the Call Site

CategoryInputOutput Behavior
#[derive(MyTrait)]The derived struct, enum, or unionAppends generated items after the original definition, preserving the original type definition
#[my_attr(...)]Attribute parameters and the annotated item (two token streams)Replaces the annotated item; can output multiple items
my_macro!(...)Token stream within delimitersReplaces the macro call; must conform to the syntax position

If an attribute macro forgets to output the original function, it will genuinely remove it; a derive macro cannot modify original fields by outputting a new struct with the same name. Macros may also error, panic, or enter infinite execution; error handling for invalid inputs should be considered part of the interface design. Rust Procedural Macros Rules

A Derive Implementation That Preserves Generics

Create the workspace root Cargo.toml:

[workspace]
members = ["hello-derive", "demo"]
resolver = "3"

hello-derive/Cargo.toml:

[package]
name = "hello-derive"
version = "0.1.0"
edition = "2024"

[lib]
proc-macro = true

[dependencies]
syn = "2"
quote = "1"

hello-derive/src/lib.rs:

use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput};

#[proc_macro_derive(Hello)]
pub fn derive_hello(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);
    if matches!(input.data, syn::Data::Union(_)) {
        return syn::Error::new_spanned(&input.ident, "Hello does not support unions")
            .to_compile_error().into();
    }
    let name = &input.ident;
    let (impl_generics, type_generics, where_clause) = input.generics.split_for_impl();
    let expanded = quote! {
        impl #impl_generics crate::Hello for #name #type_generics #where_clause {
            fn hello_name() -> &'static str {
                ::core::stringify!(#name)
            }
        }
    };
    expanded.into()
}

demo/Cargo.toml:

[package]
name = "demo"
version = "0.1.0"
edition = "2024"

[dependencies]
hello-derive = { path = "../hello-derive" }

demo/src/main.rs:

trait Hello {
    fn hello_name() -> &'static str;
}

#[derive(hello_derive::Hello)]
struct Example<T> where T: Copy {
    value: T,
}

fn main() {
    let value = Example { value: 42_u32 };
    assert_eq!(value.value, 42);
    assert_eq!(Example::<u32>::hello_name(), "Example");
    println!("{}", Example::<u32>::hello_name());
}

Running cargo run -p demo should print Example. This teaching macro explicitly requires the Hello trait to exist in the calling crate's root; a formal public library should design stable trait paths or configurable paths, and consider cases where dependencies are renamed. Do not implicitly impose this convention on all users.

Why Split Generics into Three Parts

impl<T>, Example<T>, and where T: Copy appear in different positions with different syntactic responsibilities. You cannot simply interpolate the entire Generics object in all positions. split_for_impl provides the corresponding forms for them and handles details such as default parameters that should not be repeated in the impl. syn Generics

If the generated methods read fields and require traits like Display, you need to add appropriate constraints. Do not blindly add the same bound to all generic parameters for convenience; fields might use PhantomData, associated types, or not involve that parameter at all. Design constraints based on the actual requirements of the generated code.

The example uses expanded as the variable name; gen is a reserved keyword in Rust 2024, so let gen from older tutorials cannot be used verbatim in that edition unless written as the raw identifier r#gen. gen keyword

Same Derive, Verifying Three Types of Generics and Default Parameters

Keep hello-derive unchanged and replace demo/src/main.rs with the following complete program. This example simultaneously verifies lifetimes, type parameters, const parameters, and where clauses, and uses the macro within a submodule.

trait Hello { fn hello_name() -> &'static str; }
mod model {
    #[derive(hello_derive::Hello)]
    pub struct Record<'a, T = u8, const N: usize = 2>
    where T: Copy,
    {
        pub name: &'a str,
        pub values: [T; N],
    }
}
fn main() {
    let name = String::from("sample");
    let record: model::Record<'_> = model::Record {
        name: &name, values: [1, 2],
    };
    assert_eq!(record.name, "sample");
    assert_eq!(record.values, [1, 2]);
    assert_eq!(<model::Record<'_, u8, 2> as Hello>::hello_name(), "Record");
}

The generated implementation header is structurally equivalent to impl<'a, T, const N: usize> crate::Hello for Record<'a, T, N> where T: Copy. Default parameters are not repeated at the impl declaration site, but T and N must be passed when applying the type; the where clause follows the type. The tokens at these three positions are not the same syntax; directly interpolating the entire declaration repeatedly may generate invalid code.

Here, the root trait is still found as crate::Hello within the submodule. If the trait is moved elsewhere, you must synchronously change the generated path or design a configurable entry point; "submodule call succeeds" does not mean "any dependency layout is automatically adapted."

If Generated Code Doesn't Use Fields, Don't Impose Field Constraints

Here is another replaceable demo/src/main.rs. Hello only returns the type name and does not read or clone fields, so the implementation should hold even if T does not implement Clone, Copy, or Display.

trait Hello { fn hello_name() -> &'static str; }
struct NoTraits;
#[derive(hello_derive::Hello)]
struct Wrapper<T>(std::marker::PhantomData<T>);
fn main() {
    let _value = Wrapper::<NoTraits>(std::marker::PhantomData);
    assert_eq!(Wrapper::<NoTraits>::hello_name(), "Wrapper");
}

If you later add functionality to print fields, you need to constrain the types of fields that are actually formatted, rather than mechanically adding Display to all T. For example, if a field is <T as Trait>::Item, the required capability might fall on the associated type; if a field is PhantomData<T>, T might not need to have the same capability. Macro authors should deduce constraints from the actual operations performed by the generated methods.

Why split one generic declaration into three parts?

Defaults expose careless copying: a struct may declare T=u8, but an impl must not repeat the default, and Record’s application uses only T. Toggle the syntax elements to see that split_for_impl organizes position-specific syntax rather than type-checking fields.

Preparing the visual
Why split one generic declaration into three parts?

impl declares parameters, type application supplies arguments, and where retains bounds; defaults are not repeated in impl.

syn and quote Are Not Type Checkers

syn parses syntax trees, and quote constructs tokens via interpolation. Seeing a field type alias Alias does not mean the macro can query what type it ultimately resolves to; guessing types via token strings is also susceptible to changes in paths, generics, and formatting.

Procedural macros are generally unhygienic; generated names and paths are influenced by the calling environment. Prioritize using appropriate qualified paths to avoid conflicts with user items, and use input spans to point errors to the relevant declarations. Converting TokenStream to a string and then performing string replacement usually loses structure and diagnostic quality.

Helper Attributes Also Require Complete Syntax Contracts

If a derive wants to accept #[hello(...)], you must register the corresponding helper attribute in #[proc_macro_derive(Hello, attributes(hello))] and handle it in the parsing code. Registration only allows the compiler to accept the attribute; it does not automatically execute configuration, nor does it validate its position or content for the macro.

For example, when preparing to support #[hello(trait_path = "crate::api::Hello")], you should first define the following behaviors; the minimal Hello implementation above does not yet provide this option.

InputResult the Macro Must Explicitly Decide
Path not configuredUse the default path specified in the documentation
Configured with a valid path stringParse into syn::Path and use it in the generated code
Unknown key namePoint to the key and report an unsupported option
Path specified multiple timesReject duplicates or define clear override rules to avoid accidentally taking the last one
Invalid path syntaxReport a parsing error at the input string location
Attribute placed in an unsupported locationExplicitly reject it to avoid silent ignoring

syn's parse_nested_meta is suitable for common key-value/nested attribute syntax, and LitStr can be further parsed into a Path; custom DSLs can implement Parse. Do not convert the entire attribute to a string first and then rely on splitting by commas and equals signs, as parentheses grouping, escaping, and nesting will break this parsing method. syn 2 Attribute Parsing

Attribute Syntax ErrorsKeys, duplicates, syntaxUnsupported Structurese.g., Hello rejects unionsGenerated type errorsChecked in the target crateLeave errors to the stage that understands them best, and point to the input locationPreserve input spans; explain the error and where to fix itDo not turn all failures into "proc macro panicked", nor guess types using stringsThese three types of checks have different responsibilities; a macro being able to parse syntax does not mean it possesses all the information of a type checker.

Crate Dependency Direction

Macro definitions must reside in the root of the proc-macro crate and cannot be used directly within the same crate where they are defined; they can be called by independent users, integration tests, etc. This crate can have private helper functions and modules, but it cannot export arbitrary public runtime APIs like a normal library.

Workspace members can certainly depend on another member providing a procedural macro; the restriction is that the dependency graph must not contain cycles. A common structure is separating runtime interface libraries and derive libraries, with the interface library re-exporting the macro if necessary; the fact that generated code references a certain path does not mean the macro crate itself must depend on that runtime crate.

Macro execution can access host resources such as the file system, so builds should control external inputs, avoid relying on the accidental current directory, and avoid untraceable environment changes. Across compilations, cfg inside macros sees the host configuration; target conditions usually need to generate cfg in the target code or use explicitly passed build information.

Generated Target Paths vs. Macro's Own Build Dependencies

If a macro outputs ::runtime_api::Trait, this requires the user to be able to resolve that path when compiling the generated code; it does not mean the macro itself is currently calling runtime_api code. The macro crate can depend only on syn/quote to generate tokens; the runtime API crate can then re-export the derive, without necessarily creating a reverse dependency cycle.

Dependency renaming changes the crate name visible to the user. Public macros can provide path configuration or use a verified dependency name resolution scheme; hardcoding an apparently absolute path only solves some name conflicts but does not solve the renaming problem. You cannot use $crate (specific to declarative macros) as a general path patch in procedural macros.

Usage of Diagnostics and Expanded Results

Expected erroneous inputs should use syn::Error etc. to generate compile_error!, preserving location and clear messages, rather than using panic to report all errors. The union example above gives a reason for rejection at the type name.

cargo expand is an additionally installed tool that helps view the generated structure. It displays a text-based expansion result and cannot fully preserve hygiene and other compiler internal information; therefore, the output is not guaranteed to behave identically if copied, nor is it the final assembly. cargo-expand

When verifying procedural macros, at least check simple types, generics and where clauses, module locations, and unsupported inputs. Just seeing that the example code expands "looks like an impl" is not enough to prove that the generated library is suitable for real callers.

Error Samples Must Fail to Compile, Success Samples Must Be Actually Called

Use CaseBehavior to Verify
Normal structGenerated implementation is callable, original fields still exist
Lifetime + Type + Const GenericsImplementation header and type application parameters are valid
Default Generic ParametersDefault values are not copied to the impl generic declaration
Types in SubmodulesGenerated paths resolve according to convention
Generic Parameters Without Extra TraitsNo irrelevant constraints are added due to macro generation
UnionExplicitly reports "Hello does not support unions"
Trait Does Not Exist or Is RenamedCannot pretend the generated code has passed name checking

You can use a dedicated compile test framework to manage pass/fail cases, or compile independent samples separately in a small workspace. Diagnostic assertions should focus on the core messages and key locations provided by the macro; full rustc output may change with versions, so check the reasons for changes when updating snapshots.

cargo expand helps check "what was generated," running samples checks "if the interface works as expected," and failure samples check "which inputs are explicitly unsupported." These three types of evidence complement each other; printing out beautiful tokens is not enough to guarantee that generics, paths, and diagnostics hold up for real users.