Skip to content

Function-Like Procedural Macros

21 min read

A function-like procedural macro is invoked exactly like the macros you already know — foo!(...) — but instead of pattern-matching tokens the way macro_rules! does, it is an actual Rust function that receives a TokenStream, runs real Rust code at compile time, and returns the TokenStream it generated. That extra power lets a function-like macro do things a declarative macro cannot: parse a custom mini-language, talk to the file system, and validate its arguments at compile time so a bad input becomes a compiler error rather than a runtime exception. This page is about when to reach for a function-like proc macro versus a macro_rules! one — the deep mechanics of writing them live in Procedural Macros.

Note: The current stable toolchain is Rust 1.96.0 on the 2024 edition; cargo new selects the newest edition automatically. The procedural-macro examples here were compiled and run with syn 2.0, quote 1.0, and proc-macro2 1.0.


There is no compile-time code generation in TypeScript or JavaScript, so the closest analog to “call a thing with name(...) and have it process arbitrary input and validate it” is an ordinary runtime function. The catch is in the word runtime: any validation it performs only happens when the program actually executes that line.

// A runtime "route registry" — the nearest JavaScript/TypeScript analog to a function-like macro.
type Handler = () => string;
function makeRoute(method: string, path: string, handler: Handler) {
// Validation can ONLY happen at runtime — when this code actually executes.
if (!path.startsWith("/")) {
throw new Error(`route path must start with '/': got ${path}`);
}
return { key: `${method.toUpperCase()} ${path}`, handler };
}
const ok = makeRoute("get", "/users", () => "users");
console.log(ok.key); // GET /users
try {
makeRoute("get", "users", () => "oops"); // bad path — no leading slash
} catch (e) {
console.log("caught at runtime:", (e as Error).message);
}

Running it (Node v22, via tsx) prints:

GET /users
caught at runtime: route path must start with '/': got users

The bad path is accepted by the compiler and only blows up when that branch runs. If makeRoute("get", "users") sits behind a rarely-hit code path, it can ship to production undetected. Hold onto that: a function-like proc macro turns exactly this kind of check into a compile error.


A function-like procedural macro lives in its own crate (a crate with proc-macro = true). Here is the macro definition, then how a consumer calls it. The macro parses two string literals and rejects a path that does not start with /at compile time.

// ===== crate `mymacros` (lib.rs) — must be a proc-macro crate =====
use proc_macro::TokenStream;
use quote::quote;
use syn::parse::{Parse, ParseStream};
use syn::{parse_macro_input, LitStr, Token};
// We teach `syn` how to parse our input: two string literals separated by a comma.
struct Route {
method: LitStr,
_comma: Token![,],
path: LitStr,
}
impl Parse for Route {
fn parse(input: ParseStream) -> syn::Result<Self> {
Ok(Route {
method: input.parse()?,
_comma: input.parse()?,
path: input.parse()?,
})
}
}
#[proc_macro]
pub fn route(input: TokenStream) -> TokenStream {
let Route { method, path, .. } = parse_macro_input!(input as Route);
let path_value = path.value();
// Real Rust code, running in the compiler: validate the literal.
if !path_value.starts_with('/') {
return syn::Error::new(path.span(), "route path must start with '/'")
.to_compile_error()
.into();
}
let combined = format!("{} {}", method.value().to_uppercase(), path_value);
quote! { #combined }.into() // emit a &'static str literal
}
// ===== crate `app` (main.rs) — depends on `mymacros` =====
use mymacros::route;
fn main() {
// Each call is parsed, validated, and replaced by a string literal — at compile time.
let users: &str = route!("get", "/users");
let health: &str = route!("POST", "/health");
println!("{users}");
println!("{health}");
}

Real output:

GET /users
POST /health

And the payoff — give it a bad path and the program does not compile:

// does not compile — path has no leading '/'
use mymacros::route;
fn main() {
let bad: &str = route!("get", "users");
println!("{bad}");
}

Real compiler error:

error: route path must start with '/'
--> src/main.rs:4:35
|
4 | let bad: &str = route!("get", "users");
| ^^^^^^^

The error points straight at the offending literal, with our own message. The runtime exception from the TypeScript version is now impossible: the broken route never makes it into a binary.

Note: Function-like proc macros must be defined in a dedicated proc-macro crate; the consumer crate adds it as a dependency. The boilerplate of setting that up — and the full syn/quote story — is covered in Procedural Macros. To follow along, cargo add syn --features full, cargo add quote, and cargo add proc-macro2 in the macro crate.


Every function-like procedural macro has the same signature:

#[proc_macro]
pub fn my_macro(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
// ...
}
  • The #[proc_macro] attribute marks it as a function-like macro (as opposed to #[proc_macro_derive(...)] for derives or #[proc_macro_attribute] for attribute macros — see Derive Macros and Attribute Macros).
  • It takes one TokenStream (everything inside the ( ), [ ], or { } of the call) and returns one TokenStream (the code that replaces the call).
  • It is a genuine Rust function that runs inside rustc during compilation. It can use loops, if, the standard library, helper crates — anything.

The caller writes route!("get", "/users") and the compiler hands the tokens "get" , "/users" to your function. Whatever tokens you return are spliced back in where the call appeared.

The two workhorse crates:

  • syn turns the raw TokenStream into structured data. parse_macro_input!(input as Route) runs the Parse implementation we wrote and either produces a Route or emits a parse error for us. syn already knows how to parse LitStr (a string literal), Ident, LitInt, punctuation tokens like Token![,], and full Rust syntax.
  • quote is the inverse: the quote! { ... } macro is a templating language for building a TokenStream. Inside it, #combined interpolates the value of the variable combined.

This split — parse with syn, generate with quote — is the standard structure of essentially every procedural macro.

Why this is “more power” than macro_rules!

Section titled “Why this is “more power” than macro_rules!”

A macro_rules! macro matches token patterns and substitutes; it can see tokens but it cannot compute with their contents. It cannot ask “does this string literal start with a slash?” because it has no way to inspect the characters inside a literal. A function-like proc macro can, because by the time your function runs you have the literal’s value as a normal Rust String:

let path_value = path.value(); // a real String we can call .starts_with('/') on

That single capability — running arbitrary logic over the parsed input — is the line between the two macro families.

Function-like proc macros can validate, count, and even read files

Section titled “Function-like proc macros can validate, count, and even read files”

Because the body is ordinary Rust, a function-like macro can:

  • Validate input and emit a custom compile error (the route! example).
  • Compute values at compile time (parse and re-emit, fold constants, build lookup tables).
  • Read the file system at build time — this is exactly how the standard include_str! and include_bytes! work, and how SQL crates like sqlx’s query! validate your SQL against a real database schema during compilation.

None of these are possible with a declarative macro alone.

Function-like macros shine at parsing a custom syntax that is not valid expression-grammar Rust. Here a config! macro reads key = value pairs and emits a struct literal:

// ===== in the proc-macro crate =====
use proc_macro::TokenStream;
use quote::quote;
use syn::parse::{Parse, ParseStream};
use syn::punctuated::Punctuated;
use syn::{parse_macro_input, Ident, LitInt, LitStr, Token};
struct Setting {
key: Ident,
value: SettingValue,
}
enum SettingValue {
Str(LitStr),
Int(LitInt),
}
impl Parse for Setting {
fn parse(input: ParseStream) -> syn::Result<Self> {
let key: Ident = input.parse()?;
input.parse::<Token![=]>()?;
let value = if input.peek(LitStr) {
SettingValue::Str(input.parse()?)
} else {
SettingValue::Int(input.parse()?)
};
Ok(Setting { key, value })
}
}
struct ConfigInput {
settings: Punctuated<Setting, Token![,]>,
}
impl Parse for ConfigInput {
fn parse(input: ParseStream) -> syn::Result<Self> {
Ok(ConfigInput {
settings: Punctuated::parse_terminated(input)?,
})
}
}
#[proc_macro]
pub fn config(input: TokenStream) -> TokenStream {
let parsed = parse_macro_input!(input as ConfigInput);
let fields = parsed.settings.iter().map(|s| {
let key = &s.key;
match &s.value {
SettingValue::Str(lit) => quote! { #key: #lit.to_string() },
SettingValue::Int(lit) => quote! { #key: #lit },
}
});
quote! {
Config { #( #fields ),* }
}
.into()
}
// ===== in the consumer crate =====
use mymacros::config;
#[derive(Debug)]
struct Config {
host: String,
port: u16,
}
fn main() {
let cfg = config! {
host = "localhost",
port = 8080,
};
println!("listening on {}:{}", cfg.host, cfg.port);
}

Real output:

listening on localhost:8080

The config! { ... } block is not a Rust expression you could write by hand — host = "localhost" is not valid in an expression position. The macro defines its own grammar, parses it with syn, and emits a normal Config { .. } literal. This is the kind of mini-DSL that function-like proc macros power — for instance sqlx’s query! (which type-checks SQL against a real schema at compile time) or the html!/view! macros in web frameworks like Yew and Leptos. (Note that not every DSL needs a proc macro: serde_json::json!, despite its rich syntax, is a declarative macro_rules! macro — a reminder that the declarative family is more capable than it first appears.)


Function-like proc macro vs. declarative macro_rules!

Section titled “Function-like proc macro vs. declarative macro_rules!”
Aspectmacro_rules! (declarative)#[proc_macro] (function-like procedural)
How it worksPattern-match token trees, substituteA Rust function: TokenStream in, TokenStream out
Where it livesAnywhere (inline in any module)A separate proc-macro = true crate
Can run arbitrary logicNo — match and emit onlyYes — full Rust, loops, std, helper crates
Inspect a literal’s contentsNoYes (lit.value() gives a String/number)
Validate input at compile timeOnly structural shapeArbitrary checks → custom compile_error!
Read files at build timeNoYes (like include_str!)
Compile-time costCheap, fastCompiles a whole crate + syn; slower builds
Best forVariadic construction, simple templatesCustom DSLs, validation, codegen from input

Function-like proc macro vs. derive vs. attribute

Section titled “Function-like proc macro vs. derive vs. attribute”

All three are procedural macros (real functions in a proc-macro crate), distinguished by how they are invoked:

KindInvoked asAttribute on the definitionTypical job
Function-likefoo!(...) / foo! { ... }#[proc_macro]Parse a call’s arguments, emit code
Derive#[derive(Foo)] on a type#[proc_macro_derive(Foo)]Generate impls for a struct/enum
Attribute#[foo] on an item#[proc_macro_attribute]Transform/wrap the item it decorates

This page is about the first row; the other two are in Derive Macros and Attribute Macros.

Function-like macros can appear in many positions

Section titled “Function-like macros can appear in many positions”

Like macro_rules! macros, a function-like proc macro call can stand wherever its output is valid: in expression position (let x = foo!();), statement position, or item position (generating whole functions, structs, or impls at module level). The config! example produced an expression; the routes! macro below produces an entire function. A plain function call can only ever be an expression.


Pitfall 1: Defining a #[proc_macro] in a normal crate

Section titled “Pitfall 1: Defining a #[proc_macro] in a normal crate”

Function-like procedural macros must live in a crate whose Cargo.toml declares [lib] proc-macro = true. Putting #[proc_macro] in a regular binary or library crate fails:

// does not compile — this is a normal binary crate, not a proc-macro crate
use proc_macro::TokenStream;
#[proc_macro]
pub fn noop(_input: TokenStream) -> TokenStream {
TokenStream::new()
}
fn main() {}

Real compiler error:

error: the `#[proc_macro]` attribute is only usable with crates of the `proc-macro` crate type
--> src/main.rs:3:1
|
3 | #[proc_macro]
| ^^^^^^^^^^^^^
error[E0432]: unresolved import `proc_macro`
--> src/main.rs:1:5
|
1 | use proc_macro::TokenStream;
| ^^^^^^^^^^ use of unresolved module or unlinked crate `proc_macro`

The fix is to create a dedicated crate (often named yourcrate-macros) with proc-macro = true, and depend on it from your application crate. See Procedural Macros for the full workspace layout.

Pitfall 2: Expecting it to accept runtime values

Section titled “Pitfall 2: Expecting it to accept runtime values”

A function-like macro receives source tokens, not values. If your Parse impl expects string literals, you cannot pass a variable:

// does not compile — `route!` parses string LITERALS, not runtime variables
use mymacros::route;
fn main() {
let method = "get";
let r: &str = route!(method, "/users");
println!("{r}");
}

Real compiler error:

error: expected string literal
--> app/src/main.rs:6:26
|
6 | let r: &str = route!(method, "/users");
| ^^^^^^

This is the deepest mental-model shift for a TypeScript/JavaScript developer: the macro runs before there is any such thing as the value of method. It sees the identifier token method and your parser rejects it because it wanted a literal. If you need a runtime value, use a function, not a macro.

Pitfall 3: Assuming it is zero-cost to compile

Section titled “Pitfall 3: Assuming it is zero-cost to compile”

A declarative macro is essentially free at build time. A function-like proc macro drags in syn, quote, and proc-macro2, all of which must compile, and the macro crate compiles as its own unit. For a one-off “build this string” task that macro_rules! could handle, the proc-macro overhead is rarely worth it. (The expansion itself is still zero runtime cost — only build time is affected.)

Pitfall 4: Forgetting --features full on syn

Section titled “Pitfall 4: Forgetting --features full on syn”

syn’s default features only parse a subset of Rust. If you parse expressions, items, or use Punctuated, you typically need the full feature; otherwise you get confusing “cannot find type” or “no method” errors at compile time of the macro crate. Add it explicitly:

[dependencies]
syn = { version = "2", features = ["full"] }
quote = "1"
proc-macro2 = "1"

Pitfall 5: Reaching for a proc macro when macro_rules! suffices

Section titled “Pitfall 5: Reaching for a proc macro when macro_rules! suffices”

The single most common design mistake is jumping to a procedural macro for something declarative macros do cleanly. If you only need variadic construction or a “this expands to that” template, write a macro_rules! — it is simpler, faster to compile, and needs no extra crate. Use the decision rule in Best Practices below.


Decide declarative-first, procedural-only-when-needed

Section titled “Decide declarative-first, procedural-only-when-needed”

Use this checklist before writing a function-like proc macro:

  1. Can a plain function do it? If the inputs are runtime values, use a function. Stop.
  2. Can macro_rules! do it? If you only need variadic args, simple repetition, or a fixed “expands to that” template, write a declarative macro (see Declarative Macros and Repetition). Stop.
  3. Do you need to inspect or validate the input’s contents, parse a custom grammar, or read files at build time? Now a function-like procedural macro earns its keep.

When you reject input, build the error with syn::Error::new(span, message) and return .to_compile_error(). Attaching the right span (here path.span()) makes the caret point at the exact token, just like a built-in error. Vague errors with the wrong span are a frequent source of user frustration.

Put as little logic as possible directly in the #[proc_macro] function; factor parsing and generation into helper functions and types so you can unit-test them. Procedural-macro code that returns a proc_macro2::TokenStream from a helper is far easier to test than one tangled with proc_macro::TokenStream (which only exists inside the compiler).

The community convention is to name the proc-macro crate <yourcrate>-macros (or <yourcrate>-derive) and re-export its macros from your main crate so users see a single dependency. Many crates you already use — serde, tokio, clap — do exactly this.

Use syn 2.x, quote 1.x, and proc-macro2 1.x — the current, stable, near-universal stack. (syn 1.x is legacy; new code should target 2.x.)


A production-flavored routing-table DSL. The macro reads entries of the form METHOD "/path" => handler, validates every path at compile time, and generates a dispatch function — an item, not just an expression. This is the same idea behind web-framework route macros, distilled.

// ===== crate `mymacros` (lib.rs) =====
use proc_macro::TokenStream;
use quote::quote;
use syn::parse::{Parse, ParseStream};
use syn::punctuated::Punctuated;
use syn::{parse_macro_input, Ident, LitStr, Token};
struct RouteEntry {
method: Ident,
path: LitStr,
handler: Ident,
}
impl Parse for RouteEntry {
fn parse(input: ParseStream) -> syn::Result<Self> {
let method: Ident = input.parse()?;
let path: LitStr = input.parse()?;
input.parse::<Token![=>]>()?;
let handler: Ident = input.parse()?;
Ok(RouteEntry { method, path, handler })
}
}
struct RoutesInput {
entries: Punctuated<RouteEntry, Token![,]>,
}
impl Parse for RoutesInput {
fn parse(input: ParseStream) -> syn::Result<Self> {
Ok(RoutesInput {
entries: Punctuated::parse_terminated(input)?,
})
}
}
#[proc_macro]
pub fn routes(input: TokenStream) -> TokenStream {
let parsed = parse_macro_input!(input as RoutesInput);
let mut arms = Vec::new();
for entry in parsed.entries.iter() {
let path_value = entry.path.value();
// Compile-time validation, once per route.
if !path_value.starts_with('/') {
return syn::Error::new(entry.path.span(), "route path must start with '/'")
.to_compile_error()
.into();
}
let method = entry.method.to_string().to_uppercase();
let path = &entry.path;
let handler_name = entry.handler.to_string();
arms.push(quote! {
(#method, #path) => #handler_name,
});
}
quote! {
fn dispatch(method: &str, path: &str) -> &'static str {
match (method, path) {
#( #arms )*
_ => "404 Not Found",
}
}
}
.into()
}
// ===== crate `app` (main.rs) =====
use mymacros::routes;
// Expands into a whole `fn dispatch(..)` at module level (item position).
routes! {
GET "/users" => list_users,
POST "/users" => create_user,
GET "/health" => health_check,
}
fn main() {
println!("{}", dispatch("GET", "/users"));
println!("{}", dispatch("POST", "/users"));
println!("{}", dispatch("DELETE", "/users"));
}

Real output:

list_users
create_user
404 Not Found

Three things to notice. First, the macro runs a loop over the parsed entries — declarative macros cannot do arbitrary iteration with logic in the body like this. Second, every path is validated during compilation; a typo’d "users" would be a compiler error before the program ever runs, unlike the TypeScript registry that only threw at runtime. Third, the macro emitted an entire function definition, demonstrating that function-like proc macros work in item position, not just as expressions.



Difficulty: Easy

Objective: Write your first function-like procedural macro that processes its input with real Rust code.

Instructions: In a proc-macro crate, write a shout! macro that takes a single string literal and expands to the same string uppercased at compile time. shout!("hello") should evaluate to the &'static str "HELLO". (Use parse_macro_input!(input as LitStr), then .value().to_uppercase(), then quote!.)

// in the proc-macro crate
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, LitStr};
#[proc_macro]
pub fn shout(input: TokenStream) -> TokenStream {
// TODO: parse a LitStr, uppercase its value, emit it
}
Solution
// in the proc-macro crate (lib.rs)
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, LitStr};
#[proc_macro]
pub fn shout(input: TokenStream) -> TokenStream {
let lit = parse_macro_input!(input as LitStr);
let shouted = lit.value().to_uppercase();
quote! { #shouted }.into()
}
// in the consumer crate (main.rs)
use mymacros::shout;
fn main() {
let s: &str = shout!("hello");
println!("{s}");
}

Output:

HELLO

The uppercasing runs in the compiler; the binary just contains the literal "HELLO".

Difficulty: Medium

Objective: Parse two arguments of different kinds and compute a result at compile time.

Instructions: Write a repeat_str! macro that takes a string literal and an integer literal, and expands to the string repeated that many times. repeat_str!("ab", 3) should evaluate to "ababab". Define a Parse impl that reads LitStr , LitInt, convert the count with count.base10_parse::<usize>(), and use str::repeat.

// in the proc-macro crate
use proc_macro::TokenStream;
use quote::quote;
use syn::parse::{Parse, ParseStream};
use syn::{parse_macro_input, LitInt, LitStr, Token};
struct RepeatInput {
text: LitStr,
_comma: Token![,],
count: LitInt,
}
impl Parse for RepeatInput {
fn parse(input: ParseStream) -> syn::Result<Self> {
// TODO: parse text, comma, count
}
}
#[proc_macro]
pub fn repeat_str(input: TokenStream) -> TokenStream {
// TODO: parse, compute the repeated string, emit it
}
Solution
// in the proc-macro crate (lib.rs)
use proc_macro::TokenStream;
use quote::quote;
use syn::parse::{Parse, ParseStream};
use syn::{parse_macro_input, LitInt, LitStr, Token};
struct RepeatInput {
text: LitStr,
_comma: Token![,],
count: LitInt,
}
impl Parse for RepeatInput {
fn parse(input: ParseStream) -> syn::Result<Self> {
Ok(RepeatInput {
text: input.parse()?,
_comma: input.parse()?,
count: input.parse()?,
})
}
}
#[proc_macro]
pub fn repeat_str(input: TokenStream) -> TokenStream {
let RepeatInput { text, count, .. } = parse_macro_input!(input as RepeatInput);
let n: usize = match count.base10_parse() {
Ok(n) => n,
Err(e) => return e.to_compile_error().into(),
};
let repeated = text.value().repeat(n);
quote! { #repeated }.into()
}
// in the consumer crate (main.rs)
use mymacros::repeat_str;
fn main() {
let r: &str = repeat_str!("ab", 3);
println!("{r}");
}

Output:

ababab

Returning e.to_compile_error() means a count that overflows usize (a literal too large to fit, like repeat_str!("ab", 99999999999999999999999999999999)) produces a clean compiler error instead of a panic inside the macro. A non-numeric token is rejected even earlier, by the count: LitInt parse step, which expects an integer literal.

Difficulty: Hard

Objective: Validate the contents of a literal and emit a custom compile error — the capability that sets function-like proc macros apart from macro_rules!.

Instructions: Write a hex_color! macro that takes a string literal like "#ff8800" and expands to a (u8, u8, u8) RGB tuple, parsed at compile time. It must accept an optional leading #, require exactly six hex digits, and emit a compile error (via syn::Error::new(span, msg).to_compile_error()) for anything else. hex_color!("#ff8800") should evaluate to (255, 136, 0), while hex_color!("#zz0011") should fail to compile.

// in the proc-macro crate
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, LitStr};
#[proc_macro]
pub fn hex_color(input: TokenStream) -> TokenStream {
// TODO: parse a LitStr, strip optional '#', validate 6 hex digits,
// parse each pair with u8::from_str_radix(.., 16), emit (r, g, b)
}
Solution
// in the proc-macro crate (lib.rs)
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, LitStr};
#[proc_macro]
pub fn hex_color(input: TokenStream) -> TokenStream {
let lit = parse_macro_input!(input as LitStr);
let s = lit.value();
let hex = s.strip_prefix('#').unwrap_or(&s);
if hex.len() != 6 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
return syn::Error::new(lit.span(), "expected a 6-digit hex color like \"#ff8800\"")
.to_compile_error()
.into();
}
let r = u8::from_str_radix(&hex[0..2], 16).unwrap();
let g = u8::from_str_radix(&hex[2..4], 16).unwrap();
let b = u8::from_str_radix(&hex[4..6], 16).unwrap();
quote! { (#r, #g, #b) }.into()
}
// in the consumer crate (main.rs)
use mymacros::hex_color;
fn main() {
let (red, green, blue): (u8, u8, u8) = hex_color!("#ff8800");
println!("rgb({red}, {green}, {blue})");
}

Output:

rgb(255, 136, 0)

Feeding it an invalid color — hex_color!("#zz0011") — produces a compile error instead. With this consumer file:

// does not compile — "#zz0011" is not valid hex
use mymacros::hex_color;
fn main() {
let _c: (u8, u8, u8) = hex_color!("#zz0011");
}

the compiler reports:

error: expected a 6-digit hex color like "#ff8800"
--> src/main.rs:4:39
|
4 | let _c: (u8, u8, u8) = hex_color!("#zz0011");
| ^^^^^^^^^

The validation runs entirely in the compiler, so an invalid color literal can never reach a running program — the kind of guarantee no runtime JavaScript validator can give you.