Skip to content
← Back to explainers

Rust explainer

Choosing between generics and macros

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

Choose an abstraction based on what varies: values, types, repeated syntax or the structure of a declaration.

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

Start by identifying what you want to reuse. If the same behavior should work with different values or types, a function or trait is often enough. If you need to generate declarations or accept a custom notation, consider a macro.

What varies? A useful starting point
Values passed to the same operation A function
Types that provide a shared behavior Generics and traits
Repeated syntax or a small custom notation macro_rules!
Fields, variants or attributes used to generate an implementation A procedural macro

These are starting points, not strict boundaries. Choose the approach whose implementation and call sites your team can understand and maintain.

Reuse behavior with generics

Needing the same operation for several types is not, by itself, a reason to generate separate implementations. This function formats any slice whose elements implement Display:

use std::fmt::Display;

fn comma_separated<T: Display>(values: &[T]) -> String {
    values.iter().map(ToString::to_string).collect::<Vec<_>>().join(",")
}

fn main() {
    assert_eq!(comma_separated(&[1, 2, 3]), "1,2,3");
    assert_eq!(comma_separated(&["red", "blue"]), "red,blue");
}

The trait bound states what the function needs. Callers can use a new type without adding it to a macro's list of supported types. A blanket trait implementation can serve a similar purpose, but it must obey coherence rules and can prevent more specific implementations that would overlap with it.

Ordinary functions also give readers a direct definition to inspect. Macros can be documented and tested too; their extra challenge is understanding both the macro definition and the code each invocation produces.

Use macro_rules! for repeated syntax

Declarative macros match token patterns and expand them into Rust code. They can accept patterns, types and repeated arguments, or emit items such as structs and implementations.

Custom notation does not automatically require a procedural macro. Here, key => value entries form a small notation accepted by macro_rules!:

macro_rules! pairs {
    ($($key:expr => $value:expr),* $(,)?) => {
        vec![$(($key, $value)),*]
    };
}

fn main() {
    let entries = pairs!["width" => 640, "height" => 480,];
    assert_eq!(entries, vec![("width", 640), ("height", 480)]);
    let empty: Vec<(&str, i32)> = pairs![];
    assert!(empty.is_empty());
}

The generated vector still has one element type, so the keys and values must be compatible with that type. A macro's flexible input syntax does not bypass type checking.

Rust functions have a fixed parameter list, but some APIs can replace a variable-length macro invocation with a slice, iterator, tuple or builder. Compare the resulting call sites before deciding that a macro is necessary.

A declarative macro can also match a struct's fields when the declaration is passed through it. It cannot inspect an unrelated type just from its name. The same limitation matters for procedural macros: tokens are not a general reflection API over compiler-resolved types.

Use a procedural macro when parsing needs more flexibility

A custom derive is useful when an implementation follows the fields or variants of the declaration it is attached to. An attribute macro can transform an item; a function-like procedural macro can parse an invocation. All work with token streams. They do not automatically receive type-checker information about what a name refers to.

A parser can be easier to maintain than a large collection of declarative matching rules. That is a reason to consider a procedural macro, even when macro_rules! could technically express the input grammar. Conversely, tt matching can handle many small languages. Text that cannot be represented directly as Rust tokens can be passed in a string literal and parsed by the macro implementation.

Procedural macros belong in a proc-macro crate and are used from another crate. Libraries such as syn and quote help parse and generate Rust, but neither is mandatory. Choose dependencies and features for the syntax you actually support. The Rust Reference describes the three macro forms and their crate requirements.

Compare maintenance costs

  • Build work: generated code must still be compiled. Procedural macro dependencies and expansion add work too. Measure clean and incremental builds rather than assuming a fixed cost for either macro kind.
  • Build-time access: procedural macros run in the compiler's environment, with access to resources such as files. Review dependencies as you would build scripts. Declarative expansion itself uses compiler matching rules, though its output can invoke other macros.
  • Diagnostics: try malformed invocations and inspect where errors point. Keep generated names and trait requirements understandable to callers.
  • Editor support: test navigation, completion and rename on representative uses. rust-analyzer supports macro expansion, but support for a particular transformation can vary. There is no universal ranking that makes every procedural macro worse than every declarative macro.
  • Testing: check representative expansions and include inputs that should fail to compile. Expansion views can help explain unexpected generated code.

Keep the public interface small

Document accepted syntax, generated items and required traits. For list-like inputs, consider accepting a trailing comma. Specify how often input expressions are evaluated and test that behavior when side effects matter.

Prefer an established macro when it solves the problem clearly, or a small ordinary API when it avoids unnecessary syntax. The useful question is how much work the abstraction saves its callers, and how much work it creates for the people maintaining it.

step 20 of 28

Macros, FFI and Type-Driven Design

Up next What an ABI is, and why FFI exists