Introduction
The builder pattern is one of the most popular use cases for custom derive macros. Instead of writing repetitive setter methods by hand for each field, a #[derive(Builder)] macro can generate the entire builder struct, its setter methods, and a build() method that validates and constructs the target type. This lesson walks through a complete, production-quality builder derive.
Key Concepts
- Builder pattern: A creational pattern where an object is constructed step-by-step through setter methods, with validation at the final
build()step. Option<T>wrapping: The generated builder struct wraps each field inOption<T>, starting asNoneand filling in as setters are called.format_ident!: Aquotemacro that creates new identifiers by combining strings, useful for generating names likePersonBuilderfromPerson.
Real World Context
The builder pattern is used throughout Rust for constructing complex objects: reqwest::Client::builder(), tokio::runtime::Builder, clap::Command::new(). The derive_builder crate on crates.io provides this exact functionality. Building it yourself is one of the best exercises for learning proc macros because it touches field iteration, type manipulation, and code generation all at once.
Deep Dive
Step 1: Define the Builder Struct
For each field in the target struct, the builder needs a corresponding Option<T> field:
rustlet builder_name = format_ident!("{}Builder", name); let builder_fields = fields.iter().map(|f| { let field_name = &f.ident; let field_type = &f.ty; quote! { #field_name: Option<#field_type> } }); let builder_defaults = fields.iter().map(|f| { let field_name = &f.ident; quote! { #field_name: None } });
The format_ident! macro creates a new identifier by appending "Builder" to the struct name. Each field becomes Option<OriginalType> initialized to None.
Step 2: Generate Setter Methods
Each setter takes a value, wraps it in Some, and returns self for chaining:
rustlet setters = fields.iter().map(|f| { let field_name = &f.ident; let field_type = &f.ty; quote! { pub fn #field_name(mut self, value: #field_type) -> Self { self.#field_name = Some(value); self } } });
This generates methods like fn name(mut self, value: String) -> Self that the user chains together.
Step 3: Generate the build() Method
The build() method unwraps each Option field, returning an error if any are missing:
rustlet build_fields = fields.iter().map(|f| { let field_name = &f.ident; let err_msg = format!("{} is required", field_name.as_ref().unwrap()); quote! { #field_name: self.#field_name.ok_or(#err_msg)? } });
This produces name: self.name.ok_or("name is required")? for each field, using the ? operator to return early on missing fields.
Complete Assembly
Putting it all together, the macro generates a builder struct, a constructor on the original type, setter methods, and a build method:
rustquote! { pub struct #builder_name { #(#builder_fields,)* } impl #name { pub fn builder() -> #builder_name { #builder_name { #(#builder_defaults,)* } } } impl #builder_name { #(#setters)* pub fn build(self) -> Result<#name, String> { Ok(#name { #(#build_fields,)* }) } } }
Users can now write:
rustlet server = ServerConfig::builder() .host("localhost".to_string()) .port(8080) .max_connections(100) .build()?; // Returns Err if any field was not set
Common Pitfalls
- Forgetting to handle
Option<T>fields — If the original struct already hasOption<T>fields, wrapping them again asOption<Option<T>>is awkward. Production builders detect this and make those fields optional in the builder. - Not supporting
Defaultvalues — Some fields have sensible defaults. A production builder combines helper attributes like#[builder(default)]with the builder pattern. - Consuming self in setters — Taking
selfby value (not&mut self) means the builder cannot be reused. This is the common pattern in Rust, but document it clearly.
Best Practices
- Return
Resultfrombuild()— Rather than panicking on missing fields, return aResultwith a descriptive error message. - Add helper attributes for customization — Support
#[builder(default = "value")]and#[builder(setter(into))]for real-world flexibility. - Generate documentation — Use
#[doc = "..."]in the generated code so the builder methods appear in rustdoc.
Summary
- The builder pattern is a natural fit for derive macros, eliminating field-by-field boilerplate.
- Fields are wrapped in
Option<T>in the builder, starting asNone. format_ident!creates derived identifiers likePersonBuilderfromPerson.- The
build()method validates that all required fields are set. - Production builders add helper attributes for defaults and type conversions.
Code Examples
use proc_macro::TokenStream;
use syn::{parse_macro_input, DeriveInput, Data, Fields, Error};
use quote::{quote, format_ident};
#[proc_macro_derive(Builder)]
pub fn builder_derive(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
match generate_builder(&input) {
Ok(tokens) => tokens.into(),
Err(err) => err.to_compile_error().into(),
}
}
fn generate_builder(input: &DeriveInput) -> Result<proc_macro2::TokenStream, Error> {
let name = &input.ident;
let builder_name = format_ident!("{}Builder", name);
let fields = match &input.data {
Data::Struct(data) => match &data.fields {
Fields::Named(f) => &f.named,
_ => return Err(Error::new_spanned(name, "Builder requires named fields")),
},
_ => return Err(Error::new_spanned(name, "Builder only works on structs")),
};
let builder_fields = fields.iter().map(|f| {
let fname = &f.ident;
let ftype = &f.ty;
quote! { #fname: Option<#ftype> }
});
let defaults = fields.iter().map(|f| {
let fname = &f.ident;
quote! { #fname: None }
});
let setters = fields.iter().map(|f| {
let fname = &f.ident;
let ftype = &f.ty;
quote! {
pub fn #fname(mut self, value: #ftype) -> Self {
self.#fname = Some(value);
self
}
}
});
let build_fields = fields.iter().map(|f| {
let fname = &f.ident;
let err = format!("{} is required", fname.as_ref().unwrap());
quote! { #fname: self.#fname.ok_or(#err)? }
});
Ok(quote! {
pub struct #builder_name {
#(#builder_fields,)*
}
impl #name {
pub fn builder() -> #builder_name {
#builder_name { #(#defaults,)* }
}
}
impl #builder_name {
#(#setters)*
pub fn build(self) -> Result<#name, String> {
Ok(#name { #(#build_fields,)* })
}
}
})
}
// Usage:
// #[derive(Builder)]
// struct ServerConfig { host: String, port: u16, max_connections: usize }
//
// let config = ServerConfig::builder()
// .host("localhost".into())
// .port(8080)
// .max_connections(100)
// .build()?;