Introduction
Helper attributes let derive macro users annotate individual fields to customize the generated code. When you write #[derive(Serialize)] and then mark a field with #[serde(skip)], that skip attribute is a helper attribute. This mechanism bridges the gap between the all-or-nothing nature of derive macros and the fine-grained control that real-world code generation requires.
Key Concepts
- Helper attribute: An attribute recognized by a derive macro, declared in the
attributes(...)parameter of#[proc_macro_derive]. #[proc_macro_derive(Name, attributes(helper))]: Registershelperas a recognized attribute, preventing the compiler from rejecting it as unknown.Attribute::parse_nested_meta(): The syn 2.0 API for parsing structured attribute arguments like#[helper(key = "value")].
Real World Context
Serde's derive macros use helper attributes extensively: #[serde(rename = "name")], #[serde(skip)], #[serde(default)]. Clap uses #[arg(short, long)]. These attributes give users control over per-field behavior without requiring a separate macro for each customization. Any derive macro that needs field-level configuration uses this pattern.
Deep Dive
Declaring Helper Attributes
Register helper attributes in the derive macro declaration:
rust#[proc_macro_derive(MySerialize, attributes(my_serde))] pub fn my_serialize_derive(input: TokenStream) -> TokenStream { // Fields can now use #[my_serde(...)] without compiler warnings todo!() }
Without the attributes(my_serde) declaration, the compiler would reject #[my_serde(skip)] as an unrecognized attribute.
Parsing Helper Attributes with syn 2.0
In syn 2.0, you parse attribute arguments using Attribute::parse_nested_meta(). This replaces the removed AttributeArgs and NestedMeta types from syn 1.x:
rustuse syn::{Attribute, Ident}; struct FieldConfig { skip: bool, rename: Option<String>, } fn parse_field_attrs(attrs: &[Attribute]) -> syn::Result<FieldConfig> { let mut config = FieldConfig { skip: false, rename: None, }; for attr in attrs { if !attr.path().is_ident("my_serde") { continue; // Not our attribute, skip } attr.parse_nested_meta(|meta| { if meta.path.is_ident("skip") { config.skip = true; Ok(()) } else if meta.path.is_ident("rename") { let value = meta.value()?; // parse the `=` let lit: syn::LitStr = value.parse()?; config.rename = Some(lit.value()); Ok(()) } else { Err(meta.error("unrecognized my_serde attribute")) } })?; } Ok(config) }
The parse_nested_meta method calls your closure once for each nested item inside the attribute parentheses. The closure receives a ParseNestedMeta value that provides the path and methods to parse values.
Using Field Configs in Code Generation
With parsed configs, you can conditionally generate code per field:
rustlet field_serializers = fields.named.iter().map(|f| { let config = parse_field_attrs(&f.attrs).expect("valid attrs"); let field_name = &f.ident; if config.skip { return quote! {}; // Skip this field entirely } let serialized_name = config.rename .unwrap_or_else(|| field_name.as_ref().unwrap().to_string()); quote! { map.insert( #serialized_name.to_string(), format!("{:?}", self.#field_name) ); } });
Fields marked #[my_serde(skip)] produce no code, and renamed fields use the custom name in the output.
Complete Example
Here is how a user would use the derive macro with helper attributes:
rust#[derive(MySerialize)] struct UserProfile { #[my_serde(rename = "user_name")] username: String, email: String, #[my_serde(skip)] password_hash: String, } // Generated serialize() method would: // - serialize username as "user_name" // - serialize email as "email" // - skip password_hash entirely
Common Pitfalls
- Forgetting to declare helper attributes — Without
attributes(my_serde)in the derive declaration, users get "unknown attribute" errors. - Using syn 1.x API —
AttributeArgsandNestedMetawere removed in syn 2.0. UseAttribute::parse_nested_meta()instead. - Not handling unknown attribute keys — If a user writes
#[my_serde(unknown_key)], your parser should return a clear error rather than silently ignoring it.
Best Practices
- Return
syn::Resultfrom attribute parsers — Propagate errors with?so the user sees a clear message pointing at the problematic attribute. - Validate combinations — Check for conflicting attributes (e.g.,
skipandrenameon the same field) and produce a descriptive error. - Document your helper attributes — Add doc comments or a README listing all supported attribute keys and their effects.
Summary
- Helper attributes let derive macro users customize per-field behavior.
- Declare them with
#[proc_macro_derive(Name, attributes(helper))]. - Parse arguments with
Attribute::parse_nested_meta()in syn 2.0. - Always handle unknown attribute keys with clear error messages.
AttributeArgsandNestedMetafrom syn 1.x are removed; do not use them.
Code Examples
use proc_macro::TokenStream;
use syn::{parse_macro_input, DeriveInput, Data, Fields, Attribute, Error};
use quote::quote;
#[proc_macro_derive(MySerialize, attributes(my_serde))]
pub fn serialize_derive(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
match generate_serialize(&input) {
Ok(tokens) => tokens.into(),
Err(err) => err.to_compile_error().into(),
}
}
fn should_skip(attrs: &[Attribute]) -> syn::Result<bool> {
let mut skip = false;
for attr in attrs {
if attr.path().is_ident("my_serde") {
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("skip") {
skip = true;
Ok(())
} else {
Err(meta.error("expected `skip`"))
}
})?;
}
}
Ok(skip)
}
fn generate_serialize(input: &DeriveInput) -> Result<proc_macro2::TokenStream, Error> {
let name = &input.ident;
let fields = match &input.data {
Data::Struct(data) => match &data.fields {
Fields::Named(f) => &f.named,
_ => return Err(Error::new_spanned(name, "expected named fields")),
},
_ => return Err(Error::new_spanned(name, "expected a struct")),
};
let serializers = fields.iter().map(|f| {
let field_name = &f.ident;
let key = field_name.as_ref().unwrap().to_string();
if should_skip(&f.attrs).unwrap_or(false) {
quote! {} // skip this field
} else {
quote! { map.insert(#key.to_string(), format!("{:?}", self.#field_name)); }
}
});
Ok(quote! {
impl #name {
pub fn serialize(&self) -> std::collections::HashMap<String, String> {
let mut map = std::collections::HashMap::new();
#(#serializers)*
map
}
}
})
}