Skip to content

Macro Basics: What Macros Are (and Are Not)

17 min read

A Rust macro is code that writes code: at compile time, the compiler expands a macro invocation like vec![1, 2, 3] into ordinary Rust before type-checking begins. Macros are how Rust gets variadic, type-generic constructs like println!, vec!, and #[derive(...)] without runtime reflection or a garbage-collected arguments object. This page is about the mental model — what macros are, what they are emphatically not (they are not functions and not decorators), how compile-time expansion and hygiene work, and when reaching for a macro is the right call.

Note: The current stable toolchain is Rust 1.96.0 on the 2024 edition; cargo new selects the newest edition automatically. Every Rust snippet here was compiled and run on stable.


In TypeScript/JavaScript there is no compile-time code generation in the language itself. The two things that feel closest to macros are functions (especially variadic ones) and decorators. Both run at runtime, and both are fundamentally different from a Rust macro.

// 1. A variadic helper — runs at runtime, fixed behavior, types erased.
function sum(...nums: number[]): number {
return nums.reduce((acc, n) => acc + n, 0);
}
console.log(sum(1, 2, 3)); // 6 — a real call happens at runtime
// 2. A "constructor helper" — also a runtime function call.
function dict<V>(...pairs: [string, V][]): Record<string, V> {
return Object.fromEntries(pairs);
}
const scores = dict(["alice", 95], ["bob", 87]);
console.log(scores); // { alice: 95, bob: 87 }
// 3. A decorator — the thing people WRONGLY compare to Rust attributes.
// It is a runtime function that receives and may replace the target.
function logged(value: Function, ctx: ClassMethodDecoratorContext) {
return function (this: unknown, ...args: unknown[]) {
console.log(`calling ${String(ctx.name)}`);
return value.apply(this, args); // wraps and calls at runtime
};
}
class Api {
@logged
fetchUser(id: number) {
return { id };
}
}
new Api().fetchUser(1); // logs "calling fetchUser", then runs

Three things to hold onto: the variadic sum/dict are runtime function calls, the generic <V> is erased before the code runs, and the @logged decorator is a runtime wrapper function. None of them generate new source code that the compiler then checks.


The Rust counterparts look superficially similar but happen entirely at compile time, before type checking, and produce real, checked Rust code.

use std::collections::HashMap;
// A variadic, type-GENERIC constructor macro. A plain function cannot do this:
// a function has a fixed arity and a single concrete element type.
macro_rules! hashmap {
// Match zero or more `key => value` pairs, allowing a trailing comma.
( $( $key:expr => $val:expr ),* $(,)? ) => {{
let mut map = HashMap::new();
$( map.insert($key, $val); )*
map
}};
}
fn main() {
// Expands at compile time into: a `let mut map`, three `insert` calls, etc.
let scores = hashmap! {
"alice" => 95,
"bob" => 87,
"carol" => 91,
};
let mut entries: Vec<_> = scores.iter().collect();
entries.sort();
println!("{entries:?}");
// The SAME macro with a totally different value type — no overloads needed,
// because the macro is expanded and type-checked fresh at each call site.
let flags = hashmap! { "debug" => true, "verbose" => false };
println!("debug = {:?}", flags.get("debug"));
}

Real output:

[("alice", 95), ("bob", 87), ("carol", 91)]
debug = Some(true)

The hashmap! invocation is replaced by the compiler with the block of code on the right-hand side of the rule. There is no hashmap function in the binary — only the HashMap::new() and insert calls it generated.

Note: The standard library does not ship a hashmap! macro (only vec!). We build one here precisely because it shows what a macro can do that a function cannot. The popular maplit crate provides a real one.


Macros run at compile time, functions run at runtime

Section titled “Macros run at compile time, functions run at runtime”

When you write square!(5) with this macro:

macro_rules! square {
($x:expr) => {
$x * $x
};
}
fn main() {
let n = 5;
println!("square = {}", square!(n)); // prints: square = 25
}

the compiler does not emit a call instruction. It textually-but-structurally substitutes the body, so square!(n) becomes n * n in the source before anything is type-checked. There is no square symbol in the compiled binary — the generated n * n is. This is zero runtime cost for the abstraction: a macro never adds a function call, a heap allocation, or a vtable lookup of its own.

Contrast with TypeScript’s square(n), which compiles to a genuine function call that the JavaScript engine executes (and may or may not inline) at runtime.

This is the single most important correction for a TypeScript/JavaScript developer:

  • A function receives values; a macro receives tokens (pieces of source code) and produces tokens.
  • A function has a fixed arity and types; a macro can accept a variable number of arguments of arbitrary, mixed shapes (that is how println!("{}", x) and vec![1, 2, 3] and hashmap!{ a => b } all work).
  • A function call exists in the running program; a macro is gone by runtime, replaced by what it generated.

The trailing ! is the syntactic flag that says “this is a macro invocation, not a function call”: println!, vec!, assert_eq!. (Attribute and derive macros use #[...] instead, covered in the sibling pages.)

Because macros take tokens, they preserve grouping

Section titled “Because macros take tokens, they preserve grouping”

A famous footgun in C’s textual macros does not happen with macro_rules! fragment matchers. Consider:

macro_rules! square {
($x:expr) => { $x * $x };
}
fn main() {
let n = 4;
let r = square!(n + 1); // captured as ONE expression: (n + 1)
println!("square!(n + 1) = {r}");
println!("manual n + 1 * n + 1 = {}", n + 1 * n + 1);
}

Real output:

square!(n + 1) = 25
manual n + 1 * n + 1 = 9

A C-style textual macro would paste n + 1 * n + 1 and print 9. Rust’s :expr fragment specifier captures n + 1 as a single, already-parsed expression node, so it expands as if parenthesized and prints 25. Rust macros operate on the parsed token tree, not raw text — they are structurally aware. (The full menu of fragment specifiers like :expr, :ident, :ty, :tt lives in Macro Patterns.)

Hygiene: macro-introduced names cannot collide with yours

Section titled “Hygiene: macro-introduced names cannot collide with yours”

This is the property that makes macro_rules! safe to use and is unlike anything in TypeScript/JavaScript text- or AST-based code generation. Identifiers a macro creates live in their own syntactic context and will not capture or be captured by identifiers at the call site:

// A macro that introduces a temporary binding `tmp` internally.
macro_rules! swap {
($a:expr, $b:expr) => {{
let tmp = $a; // this `tmp` belongs to the macro, NOT the caller
$a = $b;
$b = tmp;
}};
}
fn main() {
let mut tmp = 1; // the caller has its OWN `tmp`
let mut other = 2;
swap!(tmp, other); // works correctly despite the name clash
println!("tmp = {tmp}, other = {other}");
}

Real output:

tmp = 2, other = 1

Even though both the macro and the caller use the name tmp, they refer to different variables. The compiler tracks where each identifier was written (the macro definition vs. the call site) and keeps them separate. In JavaScript, a naive string-template code generator that emitted let tmp = ... would silently clobber a caller’s tmp. Rust’s macro hygiene prevents that class of bug entirely.

Note: Hygiene applies to identifiers the macro invents. Identifiers you pass in (here $a and $b) deliberately resolve at the call site — that is what lets swap! touch the caller’s variables. Hygiene is about accidental capture, not about blocking intentional access.

TypeScript decorators (@logged) are runtime functions that observe or wrap a target after the program starts. Rust’s #[derive(Debug)] and other attribute macros look similar but are compile-time code generators: #[derive(Debug)] literally writes a Debug implementation into your binary at compile time; there is no runtime wrapping and no reflection. Saying “Rust attributes are like decorators” is a common but misleading analogy — see Derive Macros and Attribute Macros for the real picture.


ConceptTypeScript/JavaScriptRust macro
When it runsRuntime (functions, decorators)Compile time, before type checking
What it operates onValues (and erased types)Tokens / parsed syntax
Runtime costA real call / wrapper existsNone — expands to inline code
Arity & typesFixed per functionVariadic, mixed, type-generic
Name collisionsPossible in string codegenPrevented by hygiene
Invocation markerf(...), @decname!(...), #[name], #[derive(Name)]
GenericsErased at runtimeMonomorphized at compile time
Closest TS analogynone is exactdecorators ≈ attributes (but compile-time)

Rust’s type system is strict and there is no runtime reflection (unlike, say, decorators inspecting metadata). Macros fill the gap that dynamic languages fill with runtime metaprogramming: removing boilerplate, building domain-specific syntax, and producing variadic constructs — all without paying a runtime price and without weakening type safety, because the generated code is type-checked exactly like hand-written code.

You will meet two kinds in this section:

  1. Declarative macros (macro_rules!) — pattern-match on token trees and substitute. Great for “this expands to that” templates. Covered in Declarative Macros, Macro Patterns, and Repetition.
  2. Procedural macros — small compiler plugins written in Rust that take a TokenStream and return one, typically using the syn and quote crates. These power custom #[derive(...)], attribute macros, and function-like foo!(...) procedural macros. Covered in Derive Macros, Attribute Macros, Function-like Macros, and Procedural Macros.

A macro invocation needs the bang. Without it, the compiler tries to parse a function call or expression and fails:

fn main() {
let v = vec[1, 2, 3]; // does not compile — forgot the `!`
println!("{v:?}");
}

Real compiler error:

error: expected one of `.`, `?`, `]`, or an operator, found `,`
--> src/main.rs:2:18
|
2 | let v = vec[1, 2, 3]; // forgot the !
| ^ expected one of `.`, `?`, `]`, or an operator

The fix is vec![1, 2, 3]. The compiler saw vec[...] and tried to parse it as indexing into a thing named vec, hence the confusing message — a good reminder that without the bang it is not a macro at all.

Pitfall 2: Expecting a runtime function to exist

Section titled “Pitfall 2: Expecting a runtime function to exist”

Because a macro is gone after expansion, you cannot pass it as a value, store it in a variable, or use it as a callback the way you can a JavaScript function:

// does not compile — there is no `println` value to pass around.
// let f = println; // error[E0423]: expected value, found macro `println`

A macro name on its own is not an expression. If you need first-class behavior, wrap the macro in a closure or function: let f = |s: &str| println!("{s}");.

Pitfall 3: Assuming format/argument errors are caught at runtime

Section titled “Pitfall 3: Assuming format/argument errors are caught at runtime”

println! and friends validate their format string at compile time — a genuine advantage over console.log template strings:

fn main() {
println!("{} and {}", 42); // does not compile — 2 placeholders, 1 argument
}

Real compiler error:

error: 2 positional arguments in format string, but there is 1 argument
--> src/main.rs:2:15
|
2 | println!("{} and {}", 42); // 2 placeholders, 1 argument
| ^^ ^^ --

In TypeScript, a malformed template only misbehaves at runtime, if at all.

Pitfall 4: Typos surface as “cannot find macro”

Section titled “Pitfall 4: Typos surface as “cannot find macro””

Because macros are resolved by name during expansion, a typo gives a clear (and helpfully suggestive) error:

fn main() {
primtln!("hello"); // does not compile — typo
}

Real compiler error:

error: cannot find macro `primtln` in this scope
--> src/main.rs:2:5
|
2 | primtln!("hello"); // typo
| ^^^^^^^ help: a macro with a similar name exists: `println`

Pitfall 5: Reaching for a macro when a function would do

Section titled “Pitfall 5: Reaching for a macro when a function would do”

Macros are harder to read, harder to document, and produce worse IDE and error experiences than plain functions. The biggest conceptual mistake is treating them as the default tool. See Best Practices below.


Prefer a function unless you truly need a macro

Section titled “Prefer a function unless you truly need a macro”

If a regular function (possibly generic, possibly with a trait bound) can express it, use the function. Reach for a macro only when you need one of these things a function genuinely cannot provide:

  1. Variadic arguments with mixed types — e.g. println!, vec!, hashmap!.
  2. New syntax / a mini-DSL — e.g. building a routing table or a SQL-ish query block.
  3. Operating on the source itself — capturing the text of an expression (stringify!), the current file and line (file!, line!), or generating trait impls from a type definition (#[derive(...)]).
  4. Eliminating boilerplate that would otherwise be copy-pasted across many types.

If none of those apply, a function is clearer, faster to compile, and friendlier to tooling.

A macro’s expansion is invisible at the call site, so document what it generates and give a worked example. When debugging, inspect the expansion with cargo expand (cargo install cargo-expand, then cargo expand), which prints your code with all macros expanded — covered further in Declarative Macros.

Lean on the standard library’s macros first

Section titled “Lean on the standard library’s macros first”

You rarely need to write a macro at all. vec!, format!, assert_eq!, matches!, todo!, dbg!, include_str!, and the rest cover a huge amount of ground — see Common Macros before writing your own.

Trust hygiene, but pass identifiers explicitly when you need access

Section titled “Trust hygiene, but pass identifiers explicitly when you need access”

Let the macro invent its own temporaries freely; hygiene keeps them safe. When the macro must touch a caller’s binding, take it as an argument ($a:expr / $name:ident) rather than hard-coding a name and hoping it matches.


A small, production-flavored logging macro that captures the expression source and the current location — two things a function literally cannot do, because by the time a function runs, the original source text and call site are gone. This mirrors the standard dbg! macro.

/// Logs an expression's source text, its file:line, and its value,
/// then returns the value so it can be used inline. Like a typed `console.log`
/// that also tells you WHERE and WHAT it logged — checked at compile time.
macro_rules! trace {
($e:expr) => {{
let value = $e; // hygienic temporary; cannot clash with caller code
eprintln!("[{}:{}] {} = {:?}", file!(), line!(), stringify!($e), &value);
value
}};
}
fn parse_port(raw: &str) -> u16 {
// We can drop `trace!` around any sub-expression without changing behavior.
trace!(raw.trim().parse::<u16>().unwrap_or(8080))
}
fn main() {
let port = parse_port(" 9000 ");
println!("listening on port {port}");
}

Real output (the trace! line goes to stderr, the result to stdout):

[src/main.rs:14] raw.trim().parse::<u16>().unwrap_or(8080) = 9000
listening on port 9000

The macro recorded the literal text raw.trim().parse::<u16>().unwrap_or(8080) via stringify!, the exact file!/line!, and the computed value — all resolved at compile time, with the temporary value kept hygienically separate from anything in parse_port. The standard library ships exactly this idea as dbg!:

fn main() {
let n = 5;
let doubled = dbg!(n * 2); // prints to stderr, returns the value
println!("doubled = {doubled}");
}

Real output:

[src/main.rs:3:19] n * 2 = 10
doubled = 10

Reach for dbg! (see Common Macros) before hand-rolling your own; the example above exists to show why this can only be a macro.



Difficulty: Easy

Objective: Confirm for yourself that a macro substitutes code rather than calling a function, and that fragment specifiers preserve grouping.

Instructions: Write a declarative macro max2! that takes two expressions and evaluates to the larger one. Invoke it as max2!(3 + 4, 10) and print the result. Predict the output before running.

macro_rules! max2 {
// TODO: match two expressions and expand to an `if`/`else`
}
fn main() {
println!("max2 = {}", max2!(3 + 4, 10));
}
Solution
macro_rules! max2 {
($a:expr, $b:expr) => {
if $a >= $b { $a } else { $b }
};
}
fn main() {
// `3 + 4` is captured as one expression, so this compares 7 vs 10.
println!("max2 = {}", max2!(3 + 4, 10)); // max2 = 10
}

Output:

max2 = 10

Difficulty: Medium

Objective: Use stringify! to capture an expression’s source text — something only a macro can do — and observe hygiene in action.

Instructions: Write a macro show! that takes one expression, prints it as <source> = <value> using stringify! and {:?}, and then returns the value so it can be used inline. Internally bind the value to a temporary named val; call show! from a main that also has its own val to prove the names do not collide.

macro_rules! show {
// TODO: bind to a temporary, print `stringify!` of the expr, return the value
}
fn main() {
let val = "untouched";
let x = show!(2 * 21);
println!("returned {x}, caller val = {val}");
}
Solution
macro_rules! show {
($e:expr) => {{
let val = $e; // hygienic: does NOT clash with the caller's `val`
println!("{} = {:?}", stringify!($e), val);
val
}};
}
fn main() {
let val = "untouched";
let x = show!(2 * 21);
println!("returned {x}, caller val = {val}");
}

Output:

2 * 21 = 42
returned 42, caller val = untouched

The macro’s val and the caller’s val are independent, demonstrating hygiene.

Difficulty: Hard

Objective: Build a variadic, compile-time construct that no single Rust function could express, using recursion and repetition.

Instructions: Write a macro count! that accepts any number of comma-separated expressions and expands to the number of arguments as a usize — fully at compile time. Handle the empty case count!() and a non-empty list. Verify count!(10, 20, 30, 40) is 4.

Tip: Define two rules: one for the empty input, one that peels off a head argument and recurses on the tail. Repetition syntax is detailed in Repetition.

macro_rules! count {
// TODO: base case for `()`
// TODO: recursive case `$head:expr $(, $tail:expr)*`
}
fn main() {
println!("count = {}", count!(10, 20, 30, 40));
println!("empty = {}", count!());
}
Solution
macro_rules! count {
() => { 0usize };
($head:expr $(, $tail:expr)*) => {
1usize + count!($($tail),*)
};
}
fn main() {
println!("count = {}", count!(10, 20, 30, 40)); // 4
println!("empty = {}", count!()); // 0
}

Output:

count = 4
empty = 0

Each expansion strips one argument and adds 1usize, recursing until the empty rule terminates the chain. The entire count is computed during compilation — the binary just contains the constant.