Skip to content
← All articles

Choosing: generics, macro_rules!, or a proc macro

The escalation ladder, what each rung really costs, and the live disagreement about whether proc macros outside derives belong in a codebase at all.

You now know how to write macros, which means you are at the point of maximum danger. The predictable failure mode of a macros chapter is a learner who spends the next month replacing perfectly good functions with macro_rules! definitions.

This is the decision procedure, stated as a ladder, with the disagreements included rather than smoothed over.

The ladder

Rung 1 — traits and generics. The default. Try this first, always.

Rung 2 — macro_rules!. When you need one of the three things a function fundamentally cannot do (18.1): a variable number of arguments of differing types, syntax that is not an expression, or generated items.

Rung 3 — a procedural macro. When you must inspect structure — the fields of a struct, the variants of an enum, the parameters of a function — or accept syntax that is not valid Rust.

Never skip a rung because the one above looks like more typing. Do not stay on a rung when you are clearly fighting it.

Rung 1 covers more than you think

The most common bad macro is one that exists because “I need this for five types”. That is what generics are.

// A macro, because it "has to work for i32, i64, f32, f64, u8"
macro_rules! clamp_all { ... }

// A generic function, because that is what the feature is
fn clamp_all<T: PartialOrd + Copy>(xs: &mut [T], lo: T, hi: T) { ... }

The generic version type-checks once at the definition, gives errors that point at your code, works with go-to-definition, shows inlay hints, is documented by rustdoc, and can be unit tested directly. The macro version does none of that and type-checks separately at every call site.

The signal that you have mistaken rung 1 for rung 2 is that your macro body contains no unusual syntax — it is just ordinary Rust with $t where a type should be. If a type parameter would do, use a type parameter.

Blanket impls are the other underused tool here. “The same method for everything that implements Display“ is impl<T: Display> MyTrait for T, not a macro over a list of types.

When rung 2 is right

Four honest signals:

  • Variadics. println!-shaped APIs: a variable number of arguments of unrelated types. There is no generic formulation.
  • Non-expression syntax. matches!(x, Some(n) if n > 3) takes a pattern. vec![0; 1024] takes a shape. Functions cannot receive either.
  • Item generation. A table of constants, a set of newtype wrappers, an enum plus its all() (18.9). If the output is items, a function cannot produce it.
  • Boilerplate that a trait genuinely cannot factor out — usually because the repetition is in the shape of the code rather than in its behaviour.

macro_rules! handles the large majority of real boilerplate needs, and it has two properties that make it much cheaper than rung 3: it is part of your crate, and it adds no build-time dependency and executes no code during the build.

When rung 3 is right

Two signals, and they are narrow:

  • You must inspect structure. Iterate a struct’s fields, enumerate an enum’s variants, read a function’s parameters. macro_rules! cannot do this (18.16, point 4) unless you are willing to declare every affected type through your macro, which is a large commitment.
  • The input is not Rust. SQL, JSON, HTML, a routing DSL. No fragment specifier describes it, so no declarative macro can accept it.

If neither applies, rung 3 is buying you nothing and costing you plenty.

What rung 3 actually costs

Worth listing, because the costs are diffuse and the benefits are visible:

  • A compile-time dependency on syn. syn with the full feature is a substantial crate. It is compiled once per toolchain per feature set, but on a cold build it is real time, and it lands on everyone who depends on you.
  • Arbitrary code execution during the build. A proc macro is a program rustc runs on the developer’s machine. It can read files, open sockets, spawn processes. This is not hypothetical paranoia — it is why some organisations audit proc-macro dependencies specifically.
  • A separate crate, forever, because a proc macro cannot live in the crate it transforms (18.17).
  • Worse IDE support than macro_rules!, which was already worse than plain code. Go-to-definition through a derive is unreliable; renaming a field does not update the generated code that mentions it.
  • Harder debugging. cargo expand is essential rather than optional, and the errors point at code nobody wrote.

The live disagreement

Here is where competent people genuinely differ, and it is better to know that than to be handed a rule.

Position A: proc macros outside derives should be banned. Build times are a first-order engineering concern; syn in the dependency graph is a cost every contributor pays on every clean build; attribute macros that rewrite whole items make code unreadable and undebuggable; and the “arbitrary code at build time” property is a supply-chain hole. Teams that maintain large, long-lived services often land here.

Position B: #[derive(Serialize)] is non-negotiable. Hand-writing serialisation for two hundred types is not a real alternative; the ergonomics are worth the build cost by an enormous margin; #[tokio::main] and #[test] are part of how the language is actually used, and pretending otherwise is performance art. Teams shipping application code usually land here.

Both are right about their own context. The synthesis most people converge on: derives are fine and normal; attribute macros that rewrite items deserve scrutiny; function-like proc macros are worth it when they buy compile-time checking of a foreign language (sqlx::query! catching a SQL typo at build time is a genuinely large win).

The IDE cost, stated plainly

Beginners do not anticipate this and it is the complaint that arrives loudest six months later.

Inside macro expansions, rust-analyzer degrades: inlay type hints go missing, go-to-definition lands somewhere unhelpful, “find all references” misses call sites, and rename refactors do not reach generated code. It is not a bug awaiting a fix — resolving the output requires running the expansion, and the IDE cannot always do that quickly or at all.

For a macro used ten times in one file, irrelevant. For a codebase where every handler, every model and every error type is defined through a macro, it is a permanent tax on everyone who reads the code, including you in a year.

The heuristic to keep

“Just use a macro” is usually a sign that a trait with a blanket impl was the better answer.

Ask, in order:

  1. Could a generic function or a trait do this? → do that.
  2. Do I need varargs, non-expression syntax, or generated items? → macro_rules!.
  3. Do I need to see the fields, or accept syntax that is not Rust? → proc macro.
  4. None of the above? → I have not understood the problem yet.

And when you do write a macro: keep its surface small, document its accepted syntax with examples, accept a trailing comma, and put #[warn(meta_variable_misuse)] above it while you work (18.13).