Skip to content
← Back to explainers

Rust explainer

The real limits of macro_rules!

About 5 min to read. Allow extra time to work through examples.

Choose between declarative macros, ordinary Rust code and procedural macros by understanding what each can inspect and generate.

This article uses Rust. You can make it your practice mode to browse related content across the site.

macro_rules! matches syntax and expands it into more syntax. It is useful for repeated declarations, variable-length argument lists and small custom languages. When a macro becomes difficult to write, first identify what information it needs: input tokens, resolved types, or external data.

Syntax is not resolved type information

A macro can match a type fragment such as $ty:ty, but it cannot ask which traits an expression's inferred type implements. Emit ordinary Rust that performs the operation, and let type checking resolve it:

trait Describe {
    fn describe(&self) -> String;
}

impl Describe for i32 {
    fn describe(&self) -> String { format!("integer: {self}") }
}

impl Describe for String {
    fn describe(&self) -> String { format!("text: {self}") }
}

macro_rules! describe {
    ($value:expr) => { Describe::describe(&$value) };
}

fn main() {
    assert_eq!(describe!(7_i32), "integer: 7");
    assert_eq!(describe!(String::from("hi")), "text: hi");
}

The macro emits the same method call for both inputs; trait resolution picks the implementation. In this small example a direct method call would also work. A macro is useful only if its syntax saves enough repetition to justify another layer for readers to understand.

Expansion and name resolution interact as the compiler discovers generated items and macros. The useful boundary is that a declarative macro has no general query API for resolved types. Procedural macros also receive token streams, not the compiler's inferred types or a reflection API.

Matching tokens and computing values are different jobs

Literal tokens can select rules. For example, a macro can distinguish a supplied yes token from a supplied no token. It can also emit an arithmetic expression, including one evaluated in a constant context:

macro_rules! switch {
    (yes) => { true };
    (no) => { false };
}

macro_rules! add {
    ($a:expr, $b:expr) => { ($a) + ($b) };
}

fn main() {
    const SIZE: usize = add!(2, 3);
    let values = [0_u8; SIZE];
    assert_eq!(values.len(), 5);
    assert_eq!([switch!(yes), switch!(no)], [true, false]);
}

add! substitutes syntax; constant evaluation computes the array size. Recursive rules can encode more elaborate computations, but there is no general arithmetic or string-processing function you can call inside a matcher. Token equality for selected patterns is possible; that is different from comparing the runtime values of arbitrary expressions.

Fragment choice matters. Forwarded expr fragments are opaque to another declarative macro's literal-token rules. ident, lifetime and tt fragments have exceptions that allow literal matching. Choose fragments for the syntax you need, rather than assuming all captured input remains freely inspectable.

Supply generated names explicitly

Writing get_$field does not paste two pieces into one identifier, and concat! produces a string literal rather than an identifier. A simple API can ask the caller for both names:

macro_rules! getter {
    ($method:ident, $field:ident) => {
        fn $method(&self) -> &str { &self.$field }
    };
}

struct Person { name: String }

impl Person {
    getter!(name_ref, name);
}

fn main() {
    let person = Person { name: String::from("Ada") };
    assert_eq!(person.name_ref(), "Ada");
}

Procedural macros can construct identifier tokens when derived names are genuinely useful. That requires arranging the appropriate macro crate and build dependencies; it is not supplied by a bare single-file Rust exercise. Ordinary helper locals often need no generated unique name: declarative macro hygiene keeps many local bindings separate from caller bindings.

Inspect declarations only when their syntax is available

Given only Point, a declarative macro cannot look up that type's fields. If its invocation includes the field list, it can match that supported form and generate both the struct and related code. Only declarations using that macro need to opt in; unrelated structs can remain ordinary Rust.

A derive procedural macro receives the annotated declaration's tokens, making it convenient to generate implementations from fields and variants. It can parse those tokens but still cannot generally resolve a field type alias or query every trait implemented by a field's type. Generated trait bounds can leave those checks to the compiler.

External inputs and state need explicit design

Declarative rules do not themselves perform filesystem reads, run a shell, or call a Rust library during expansion. They can emit calls to compiler built-ins such as include_str! and env!, which provide specific supported inputs. Procedural macros and build scripts have broader execution abilities; their dependencies and external inputs deserve review and reproducible builds.

There is no general mutable counter or registry shared by all macro_rules! invocations. That does not mean expansions have no effect on later code: they can define items and helper macros whose scopes matter. For a collected list, an explicit single invocation is often enough. Other designs combine macro-generated registrations with runtime or linker mechanisms; these are additional mechanisms, not hidden shared state in declarative matching.

Watch recursive expansion cost

A token-tree muncher consumes part of its input and recursively processes the rest. A design that repeatedly matches and copies a shrinking tail can have quadratic expansion work. This is a common pattern to watch, not a fixed formula for every recursive macro or a guaranteed timing at a given size.

Prefer a repetition such as $(...)* when it expresses the grammar directly. Test realistic input sizes before adding a complex recursive parser. Rust's default recursion_limit is 128; this limits certain compiler operations, including macro expansion depth, rather than imposing a universal 125-token input limit. A crate-level increase can help a deliberate design, but it does not repair accidental recursion or remove expansion costs.

Choose the smallest useful abstraction

Start with functions, traits and generics when ordinary Rust expresses the relationship. Use macro_rules! for syntax-driven repetition. Consider a procedural macro when parsing an annotated declaration or transforming token streams needs more flexible code. Both kinds of macro can accept custom syntax within their token input; custom syntax alone does not require a procedural macro.

Check the editing experience as well as compilation: navigate to definitions, rename a generated symbol and inspect an error at a call site. Editor support varies with the macro and toolchain, so evaluate the actual API instead of assuming either perfect support or universal failure.

The Rust Reference documents declarative matching, procedural macro inputs and recursion limits.

step 16 of 28

Macros, FFI and Type-Driven Design

Up next Procedural macros: the three kinds