The Rust Engineering Handbook / Chapter 77
Derive, Attribute, and Function-Like Macro API Design
Choose a procedural-macro surface by ownership, keep its grammar small, and evolve generated obligations as carefully as ordinary public API.
Three call sites are on the review screen:
#[derive(RouteSpec)]
#[route(method = "GET", path = "/accounts")]
struct Accounts;
#[route(method = "GET", path = "/accounts", register, metrics, retry = 3)]
async fn accounts(request: Request) -> Response { /* ... */ }
static ROUTES: [Route; 2] = route_table![Accounts, Health];
All three can be implemented. That is not yet a reason to implement any of them. The review question is which source construct owns the meaning. A derive can add a trait implementation to a type while leaving the type intact. An attribute macro can replace an item and therefore can change nearly everything about it. A function-like macro owns an explicit token region and can generate a collection of related items or an expression. Those authority boundaries are different user interfaces, even if their internal parser and generator share code.
The second spelling is superficially convenient and semantically crowded. It says the function is a route, requests global registration, installs metrics, and selects a retry policy. Some of those decisions change compile-time metadata; others add runtime calls, initialization, state, or failure behavior. The attribute has become a small framework whose operational contract is invisible at the call site.
The governing rule is: choose the narrowest macro form whose authority matches the source construct and make every generated obligation observable as public API. Syntax is only the front door. Trait bounds, names, visibility, documentation, runtime calls, diagnostics, and future grammar are the real compatibility surface.
Choose by ownership, not aesthetics
A custom derive receives the complete struct, enum, or union and appends items after it. It does not replace the original item. That makes derive a strong fit when the sentence “this type implements this trait according to these declarations” is accurate. Serialization, conversion, validation metadata, and the RouteSpec teaching example have this shape. The trait provides an ordinary Rust contract that can be named in bounds, documented without the macro, implemented manually, and tested through behavior.
A derive is a poor fit when the operation is not type-owned. A set of routes has relationships across several types. One derive invocation cannot reliably discover its peers, check global duplicates, or promise expansion order across a crate. Smuggling those jobs into link-time registration or process-global constructor hooks gives the derive authority the caller cannot see. Keep the type-local fact in RouteSpec; aggregate types at an explicit site.
An attribute macro receives two token streams: the attribute arguments and the annotated item. Its output replaces that item. Use this authority when transformation of the item itself is the honest contract: generating a family of test cases from a function, rewriting an item into a protocol-specific wrapper, or enforcing an item grammar that ordinary attributes cannot express. Because the macro owns the item, it must deliberately preserve visibility, generics, lifetimes, attributes, documentation, conditional compilation, ABI, safety qualifiers, and the parts of the body it promises not to change.
The replacement power makes an attribute a dangerous default for annotation. If a tool only needs inert metadata consumed by a derive, use a derive helper attribute. If it only needs a marker read by another build step, ask whether an ordinary Rust attribute or separate configuration file is clearer. An attribute macro that re-emits an almost identical function still creates a compatibility boundary around every token it parses and regenerates.
A function-like procedural macro owns the delimited tokens at an explicit invocation and may appear in macro invocation positions allowed by Rust. It is appropriate when the input is a language in its own right or when several declarations must be considered together. SQL checked against a schema snapshot, a protocol grammar, or an explicit route table can justify that surface. It is not a license to redesign Rust punctuation. Prefer Rust types, expressions, and item syntax where they already express the domain.
The Part XII lab adds this bounded aggregation:
pub fn explicit_route_table() -> [Route; 2] {
route_table![Accounts<()>, Events<'static>]
}
It expands conceptually to:
[
<Accounts<()> as ::macro_contract::RouteSpec>::route(),
<Events<'static> as ::macro_contract::RouteSpec>::route(),
]
The list is visible, ordered, and reviewable. No macro scans the crate. No inventory is initialized before main. No handler is registered merely because its module was linked. If duplicate routes must be rejected, the function-like macro owns all entries and can perform that check. If the route set must be assembled dynamically from configuration, ordinary runtime code is the more honest mechanism.
The form decision can be reviewed with three questions:
- What complete source unit must be inspected to prove the rule?
- What existing source may the expansion replace or append to?
- Where can a caller see every runtime effect and generated obligation?
If the answers do not fit the selected form, changing punctuation will not repair the design.
Give helper attributes one namespace
The RouteSpec derive declares attributes(route), making route a derive helper attribute on the item. Helper attributes are inert: they remain visible to macros but do not transform the item by themselves. That is valuable because the type transformation still has one named owner, RouteSpec.
Keep configuration under one domain namespace:
#[derive(RouteSpec)]
#[route(method = "GET", path = "/accounts")]
struct Accounts;
Compare a flat set:
#[derive(RouteSpec)]
#[get]
#[path = "/accounts"]
#[public]
#[no_metrics]
struct Accounts;
The flat form spends attribute names from a global-looking namespace, makes ownership ambiguous, and becomes collision-prone as other derives arrive. It also encourages Boolean flags that interact combinatorially. A single route(...) container gives the grammar one boundary and makes unknown-key rejection possible.
Namespacing does not excuse a large grammar. Each key needs a type, cardinality, default, duplicate policy, error span, interaction policy, and compatibility story. method = "GET" is a string literal in this API; accepting method = GET later creates a second permanent spelling unless a deprecation and migration path exists. path = "/accounts" is a literal because the derive promises a compile-time value. Accepting an arbitrary expression would change evaluation, diagnostics, and possibly runtime cost.
Order attributes so the derive introducing its helpers appears before those helpers. Current compilers have historically tolerated some out-of-order helper use, but the Reference describes that behavior as deprecated. The durable spelling is the documented scope, not an observed leniency.
Defaults are behavior, not missing syntax
A default removes source text while adding a rule callers must remember. It is justified when one choice is overwhelmingly safe, stable, and unsurprising. It is hazardous when it hides policy or makes two identical-looking items behave differently because of surrounding context.
Suppose a route API proposes these keys:
method, path, auth, timeout, retry, metrics, cache, visibility, register
Method and path describe the compile-time route contract. Authentication, timeout, retry, caching, and metrics are runtime policy with service-specific state and failure modes. register implies discovery and initialization. visibility is already represented by the Rust item. Moving all of them into the derive creates defaults whose consequences do not appear in ordinary control flow.
A smaller surface is easier to operate:
#[derive(RouteSpec)]
#[route(method = "GET", path = "/accounts")]
struct Accounts;
let handler = Retry::new(Metrics::new(AccountsHandler), retry_policy);
router.add(Accounts::route(), handler);
The macro owns static description. Ordinary constructors own runtime policy. The call site makes wrapping order, allocation, state, and failure behavior inspectable. Types can enforce that an authenticated handler is required without asking an attribute parser to become a dependency-injection container.
When a default is appropriate, document its expansion-level meaning. “Default timeout is 30” is incomplete: 30 what, enforced by which runtime, measured from which event, with what cancellation behavior? A macro should not manufacture that ambiguity. “Omitted rename uses the Rust identifier exactly” is a more suitable compile-time default because the source of the value and its effect are bounded.
Reject duplicates by default. Last-one-wins configuration turns merge artifacts into silent behavior changes. Allow repetition only when it models a collection, and specify whether order is meaningful. For mutually exclusive keys, diagnose the pair at the later key while naming the earlier choice.
Generated bounds are part of the signature
Generated trait bounds determine which downstream programs compile. They are not implementation trivia. A derive that emits T: Clone for every type parameter may exclude valid callers even if only one field is cloned. A later “optimization” that changes T: Display to T: Debug is a breaking API change for types that implement only the former.
Derive bounds from emitted operations, preferably from field types rather than blindly from every generic parameter. Consider:
struct Envelope<'a, T, U>
where
U: ?Sized,
{
id: T,
payload: &'a U,
}
If generated code only reads id through AsRef<str>, the defensible obligation may be T: AsRef<str>. Adding U: Clone because U appears in the type is unjustified. Preserve the input lifetime, const parameters, and where clause. Add a fresh lifetime or helper type parameter only if its name cannot collide and its relationship is documented.
There are three credible policies:
- Infer the minimal bound from each used field. This is ergonomic but requires careful expansion tests for associated types, projections, and user overrides.
- Require callers to state bounds in ordinary Rust and preserve them. This is explicit but may repeat an obligation the generated implementation alone needs.
- Accept a namespaced bound override. This handles exceptional types but expands the macro grammar and lets callers couple to generation details.
Choose one and test it as API. Do not alternate opportunistically between them.
Lifetimes deserve the same attention. An attribute macro wrapping async fn borrow<'a>(&'a self) must not silently require 'static because its generated future is boxed into a global registry. That is evidence that hidden registration changed the API. Preserve the function’s lifetime relationships, or make the new ownership boundary explicit in an ordinary returned type.
Const generics must also pass through unchanged unless the emitted code genuinely adds a const constraint. Generating syntax that only happens to compile for today’s stable const-expression subset is version-sensitive and belongs in the mechanism’s compatibility record rather than being hidden by the macro form.
Visibility has two directions
Review both who can invoke the macro and who can name its output. A public macro may expand inside another crate, so every path it emits must be reachable there. A generated pub item can unintentionally enlarge the consumer’s API. A private generated helper can collide with an existing name or become inaccessible from the expansion’s actual scope.
Prefer implementations and expressions over invented public names. The derive emits a trait implementation, not pub fn __route_spec_for_accounts. The function-like macro emits an array expression, so the caller chooses whether the binding is private, pub(crate), or public. When a helper is necessary, use an intentionally obscure name, keep it in a private generated module where possible, and test multiple invocations in the same scope.
Fully qualified paths make dependencies visible but introduce renaming questions. ::macro_contract::RouteSpec works when the downstream dependency has that crate name. A re-export strategy, an explicit crate = path key, or dependency-name discovery can improve ergonomics, each at a cost. An explicit path is verbose and deterministic. Discovery adds a build-time dependency and metadata behavior. A re-export couples the runtime and derive crate release plan. None is perfectly hygienic; document and test the supported contract.
Visibility inside attribute macros is easier to damage because the item is replaced. Copying pub, pub(crate), or a restricted visibility string by reconstruction is brittle. Preserve the parsed visibility node. The same applies to #[cfg], #[deprecated], #[must_use], lint attributes, and user documentation. Decide which attributes belong on the wrapper, the inner item, or both. Duplication can change linting and documentation; omission can change compilation.
Generated documentation must explain generated API
If users can name generated items, rustdoc must explain them as user-facing API. Do not expose a forest of helper types whose only documentation is “generated by macro.” Either keep helpers private or write durable docs describing invariants, errors, examples, and relationship to the annotated source.
Derive-based APIs benefit from documenting the ordinary trait first. A user should be able to understand RouteSpec, implement it manually, and know what the derive adds. The macro documentation then covers accepted input forms, defaults, generated bounds, dependency paths, examples, rejection cases, and compatibility policy. This separation prevents the macro from becoming the only specification.
An attribute macro should state what it preserves and what it replaces. If it turns one function into a wrapper plus an inner function, show the conceptual expansion. State whether documentation attaches to the wrapper, whether the original symbol remains nameable, whether async, unsafe, ABI, and generics are supported, and what runtime work the wrapper adds.
Test documentation as a downstream user sees it. Run doctests for ordinary API examples and inspect generated rustdoc for public names. Token snapshots alone cannot prove the docs link correctly or that an emitted type is reachable.
Compatibility includes accepted input and emitted output
Treat a macro release as two languages evolving together:
accepted Rust + macro grammar -> emitted Rust + diagnostics + behavior
Widening accepted syntax is not automatically compatible. Adding a new key can conflict with input that an older version rejected deliberately or that another macro already consumed. Giving meaning to a previously ignored key is especially dangerous, which is why unknown keys should fail from the first release. Adding a defaulted field can introduce a new trait bound or runtime call. Reinterpreting token order can change generated names.
Output changes can break users without changing call syntax:
- a new bound rejects previously valid generic types;
- a renamed generated method breaks direct callers;
- broader visibility creates name or lint conflicts;
- a new panic, allocation, lock, log, or registration hook changes operations;
- a different generated path breaks renamed dependencies;
- a changed diagnostic breaks deliberately supported UI expectations;
- an expansion-size increase pushes builds over their time or memory budget.
Semantic versioning must therefore review source acceptance, expansion type-checking, runtime behavior, and supported diagnostics. Exact rustc presentation is compiler-owned and should not be frozen casually. Stable domain phrases, span ownership, and repair guidance can be tested without snapshotting borders, line numbers, or note order.
For a grammar change, classify it before coding:
| Change | Likely compatibility concern | Evidence |
|---|---|---|
| add optional key with inert default | collisions and future interaction | old fixtures on new macro; unknown-key corpus |
| add required key | source breaking | migration fixture and targeted diagnostic |
| accept a second spelling | permanent grammar ambiguity | parser corpus and canonical docs |
| add inferred bound | type-checking break | generic downstream matrix |
| emit runtime registration | behavior and initialization break | reject or conduct full operational review |
| improve span only | usually presentation-compatible | phrase and token-location UI test |
“Usually” is intentional. A project may promise diagnostic strings or generated names, but that promise must be explicit and tested.
At compatibility review, collapse the chapter’s choices into one map. Read each lane from the source construct the macro may inspect, through the Rust it may generate, to the obligations that downstream callers must be able to name.
Test contracts, not just token text
The smallest credible suite has distinct layers.
Parser and validation tests prove every key, duplicate rule, default, mutual exclusion, and unsupported input. Expansion-shape tests inspect facts that behavior cannot expose easily: a bound is attached to the intended field type, an input where clause is retained, a path is qualified, or a wrapper preserves unsafe extern "C". Normalize tokens before comparison and keep snapshots narrow; whitespace is not the contract.
Downstream compile-pass fixtures prove that real crates can import the macro, rename dependencies when supported, use visibility boundaries, and instantiate generic, lifetime, and const-generic forms. Behavioral tests prove the generated implementation does what its ordinary trait claims. Doctests prove the advertised public spelling.
Compile-fail fixtures should cover unknown and duplicate keys, wrong literal types, unsupported item forms, conflicting options, missing required input, invalid generated bounds, and empty aggregate input. The lab’s route_table![] fixture expects the durable phrase:
route_table! requires at least one route type
That failure belongs at the invocation because no element token exists. For an invalid listed type, the diagnostic should point to that type. For a bad helper value, it should point to the value rather than the derive name.
Runtime tests must catch hidden behavior. Call a generated constructor twice. Check whether it allocates, registers globally, starts work, reads environment, or panics. A macro expansion is ordinary Rust after expansion; profile and instrument it like handwritten code. If the runtime effect cannot be stated plainly at the call site, move it into an ordinary function or type.
Test supported stable and MSRV toolchains, editions where compatibility is promised, feature combinations, and warnings denied. A compiler upgrade can reveal newly reserved syntax, lint generated code differently, or change proc-macro diagnostics. A dependency upgrade can change parser acceptance or quotation. Record the versions rather than calling the evidence timeless.
Simplify an attribute-heavy proposal
Perform this review before implementing the following API:
#[service_route(
method = "GET",
path = "/accounts",
auth = "staff",
retry = 3,
timeout_ms = 500,
metrics = true,
cache = "60s",
register = true
)]
pub async fn accounts(request: Request) -> Result<Response, Error> {
/* ... */
}
First, make an ownership table. Method and path describe the route. Authentication may belong in a typed middleware requirement. Retry belongs around retry-safe outbound effects, not indiscriminately around a request handler. Timeout belongs to a deadline policy with cancellation semantics. Metrics are cross-cutting runtime behavior. Cache semantics require keys, invalidation, privacy, and failure policy. Registration owns a collection wider than one item.
Second, reduce the compile-time language. One defensible design derives static route metadata from a marker type and assembles routes explicitly:
#[derive(RouteSpec)]
#[route(method = "GET", path = "/accounts")]
struct Accounts;
let accounts = Cache::new(
Metrics::new(RequireRole::new(accounts_handler, Role::Staff)),
account_cache_policy,
);
router.add(Accounts::route(), accounts);
Another defensible design uses a narrow attribute only if transforming the function is essential, while still keeping runtime layers explicit. The correct answer depends on the framework, but retaining all eight keys because a parser can accept them is not defensible.
Third, write the generated-obligation inventory:
accepted items and signatures:
preserved attributes, visibility, generics, and lifetimes:
added bounds and generated names:
qualified crate paths and rename policy:
allocation, panic, I/O, registration, and initialization:
documentation ownership:
stable diagnostic phrases and spans:
MSRV, edition, dependency, and compile-time budget:
Fourth, propose one evolution—such as supporting a path parameter—and add old-version call sites, new positive cases, ambiguous spellings, and compile failures before changing the generator. Explain whether the change widens grammar, changes emitted types, or adds runtime behavior.
The review succeeds when another engineer can predict both the expanded Rust and the operational effects without reading the proc-macro implementation.
Review rules for a durable macro language
- Choose derive for a type-local trait contract, attribute for honest item transformation, and function-like form for an explicit multi-token language or aggregation boundary.
- Keep helper keys under one domain namespace and reject unknown or duplicate input deliberately.
- Treat defaults, bounds, lifetimes, visibility, names, paths, documentation, diagnostics, and runtime calls as public API.
- Preserve Rust syntax and caller intent that the macro does not own.
- Prefer explicit aggregation to global discovery and ordinary runtime composition to hidden generated behavior.
- Test parsing, expansion facts, downstream compilation, behavior, documentation, failures, versions, and cost separately.
- Review every grammar change against both accepted input and emitted output.
Sources and version notes
Verified on 2026-07-12 against Rust 1.97.0 and the Rust 1.85.0 MSRV. The three procedural-macro forms and derive-helper behavior are documented language interfaces; exact diagnostics and the syn/quote parsing and generation behavior used by the lab remain version-sensitive third-party behavior.
- The Rust Reference: procedural macros
- The Rust Reference: attributes
- The Rust Reference: macro invocation positions
- Standard library
proc_macrodocumentation - Rust API Guidelines: macros
Macro form answers who owns syntax and generated Rust. It does not answer whether code generation is needed at all. The final decision in this part is which stage—const evaluation, macro expansion, a build script, an external generator, or runtime—should perform the work.
Continue reading
Full table of contents