Use Builders for Complex Types
Learn how the builder pattern in Rust reduces the friction of constructing structs with many fields, optional values, and validation, while keeping your API clear and safe.
When a struct has only two or three fields, creating an instance with a struct literal reads naturally. But as the number of fields grows — especially when many are optional or require validation — a plain literal becomes noisy, brittle, and easy to misuse. The builder pattern is the Rust community's answer to that problem. It wraps the construction process in a dedicated type that guides the caller, hides internal defaults, and makes it impossible to forget a required piece of data.
The Problem Builders Address
Consider a struct that represents a user profile in a backend system. It has a handful of mandatory fields and a pile of optional ones.
#[derive(Debug)]
pub struct Profile {
pub username: String,
pub display_name: String,
pub bio: Option<String>,
pub avatar_url: Option<String>,
pub email: Option<String>,
pub date_of_birth: Option<time::Date>,
}
Every caller has to fill in all the fields explicitly, even the None ones.
let profile = Profile {
username: "ferris".to_owned(),
display_name: "Ferris the Crab".to_owned(),
bio: None,
avatar_url: None,
email: Some("ferris@example.com".to_owned()),
date_of_birth: None,
};
This works but hides two pain points. First, if a new optional field is added later, every place that constructs a Profile breaks — the compiler forces you to visit each site and insert the new field. Second, it is easy to accidentally omit a mandatory field or swap two String fields because nothing distinguishes them except their position and name. The builder pattern removes both sources of fragility.
How a Basic Builder Looks
A builder is a separate struct that holds the data needed to construct the final type. It exposes a constructor for the values that have no sensible default and setter methods for everything else. When the caller is done, they call a build method that validates the data and returns the finished struct.
pub struct ProfileBuilder {
username: String,
display_name: String,
bio: Option<String>,
avatar_url: Option<String>,
email: Option<String>,
date_of_birth: Option<time::Date>,
}
impl ProfileBuilder {
pub fn new(username: impl Into<String>, display_name: impl Into<String>) -> Self {
Self {
username: username.into(),
display_name: display_name.into(),
bio: None,
avatar_url: None,
email: None,
date_of_birth: None,
}
}
pub fn bio(mut self, bio: impl Into<String>) -> Self {
self.bio = Some(bio.into());
self
}
pub fn email(mut self, email: impl Into<String>) -> Self {
self.email = Some(email.into());
self
}
pub fn build(self) -> Profile {
Profile {
username: self.username,
display_name: self.display_name,
bio: self.bio,
avatar_url: self.avatar_url,
email: self.email,
date_of_birth: self.date_of_birth,
}
}
}
The new function captures the mandatory fields. Every setter takes ownership of the builder, updates a field, and returns the builder back — this is what makes method chaining possible. The final build consumes the builder and hands over the finished product.
Usage becomes a chain of only the details the caller cares about.
let profile = ProfileBuilder::new("ferris", "Ferris the Crab")
.email("ferris@example.com")
.build();
Into<String> as parameters:
Using impl Into<String> instead of &str lets callers pass String or &str without extra conversions. It also avoids forcing a clone when the caller already owns a String. This small signature choice makes a builder feel much more ergonomic in practice.
The missing optional fields are safely filled with None internally, so the caller is never exposed to the boilerplate they don't need. If a new optional field is added later, only the builder internals change — existing call sites stay untouched.
Consuming Builders vs. Borrowing Builders
The setter methods above take mut self and return Self. This is the consuming builder style. It works beautifully for one-shot constructions, but it has a subtlety that can trip up newcomers: the builder moves away after every method call. That means you cannot conditionally call a setter and then continue using the same variable unless you reassign it.
let builder = ProfileBuilder::new("ferris", "Ferris the Crab");
// This would fail: builder.email(...) moves builder, so builder is gone.
// Must reassign:
let builder = if wants_email {
builder.email("ferris@example.com")
} else {
builder
};
let profile = builder.build();
An alternative is the borrowing builder style, where setters take &mut self and return &mut Self. This removes the need to reassign, but at the cost of chaining across multiple statements.
impl ProfileBuilder {
pub fn email(&mut self, email: impl Into<String>) -> &mut Self {
self.email = Some(email.into());
self
}
// Same for other setters...
}
Now conditional logic looks cleaner:
let mut builder = ProfileBuilder::new("ferris", "Ferris the Crab");
if wants_email {
builder.email("ferris@example.com");
}
let profile = builder.build();
The trade-off: you cannot chain the constructor and the setters in a single expression like ProfileBuilder::new(...).email(...).build(). The compiler complains that the temporary builder is dropped while still borrowed.
Choose the style based on usage:
Both patterns are fine. Use the consuming style when the builder is typically built in one fluent chain. Use the borrowing style when conditional field assignment is common, especially inside functions where you are piecing together a configuration from multiple sources.
Compile-Time Guarantees with the Type-State Pattern
The builders shown so far check at runtime — if a mandatory field was somehow missed (perhaps new didn't cover it), the mistake only surfaces when you forget to call the setter and build still compiles. The type-state builder pattern moves that check to compile time by encoding which fields have been set into the type of the builder itself.
In a hand-rolled type-state builder, the builder changes its type every time a required field is set. The build method is only available on the final type that represents "all required fields are set."
A minimal sketch:
pub struct ProfileBuilder<State> {
username: Option<String>,
display_name: Option<String>,
bio: Option<String>,
_state: std::marker::PhantomData<State>,
}
// Marker types for each field's completion status
pub struct MissingUsername;
pub struct HasUsername;
pub struct MissingDisplayName;
pub struct HasDisplayName;
impl ProfileBuilder<(MissingUsername, MissingDisplayName)> {
pub fn new() -> Self {
ProfileBuilder {
username: None,
display_name: None,
bio: None,
_state: std::marker::PhantomData,
}
}
pub fn username(self, value: String) -> ProfileBuilder<(HasUsername, MissingDisplayName)> {
ProfileBuilder { username: Some(value), ..self }
}
}
impl ProfileBuilder<(HasUsername, MissingDisplayName)> {
pub fn display_name(self, value: String) -> ProfileBuilder<(HasUsername, HasDisplayName)> {
ProfileBuilder { display_name: Some(value), ..self }
}
}
impl ProfileBuilder<(HasUsername, HasDisplayName)> {
pub fn build(self) -> Profile {
Profile {
username: self.username.unwrap(),
display_name: self.display_name.unwrap(),
bio: self.bio,
avatar_url: None,
email: None,
date_of_birth: None,
}
}
}
Trying to call build before both username and display_name are set results in a compiler error: no method named 'build' found. The error message explicitly names the current state type, which tells the developer exactly what is missing.
Compile-time safety without runtime overhead:
The phantom type parameters are zero-sized — they exist only at compile time and add no cost. When the code compiles, the builder has been used correctly.
Manually writing these state transitions is tedious, which is why crates like typed-builder, bon, and type-state-builder exist. They derive the type-state machinery from your struct definition with a single attribute.
Builders for Generic Structs
A generic struct introduces an extra challenge: the builder must carry the same type parameters, but the caller may not know concrete types until later in the construction. For example:
pub struct Service<D, R> {
pub driver: D,
pub retry: R,
pub timeout: std::time::Duration,
}
A straightforward builder would tie D and R to the builder's type immediately, forcing the user to commit to both types when they call new. In practice, a user might know the driver type early but decide on the retry type later, in a different branch.
The lazy generics approach decouples type inference by storing the field values as trait objects or as Option<T> and deferring final type resolution to build.
pub struct ServiceBuilder<D> {
driver: D,
retry: Option<Box<dyn std::any::Any>>, // store any retry
timeout: std::time::Duration,
}
In build, you downcast retry to the required type. This is rarely needed in application code, but it's a useful pattern for library authors who need maximum call-site flexibility.
For most cases, a simpler solution is to make build generic over the unresolved types, letting the caller supply them at the last moment. Crates like bon can handle this automatically.
Real-World Usage in the Standard Library
The builder pattern is not just a community convention; it appears in Rust's standard library where configuration is complex.
std::fs::OpenOptionsis a builder forFile. It lets you chain.read(true),.write(true),.create(true)and then call.open(path).std::process::Commandbuilds a child process with arguments, environment variables, and I/O redirections before calling.spawn()or.output().std::thread::Builderconstructs threads with a custom name and stack size.
These APIs all use the borrowing builder style (&mut self) because the builder is often reused or configured across multiple scopes. The final step is not called build but a method that does the actual work — open, spawn, spawn, etc. — which reinforces that the builder itself is not the product; it's just the recipe.
Implementing a Builder from Scratch
When you decide to add a builder to your own type, a few concrete steps turn the idea into working code. The order matters: you can't write the build method until you know what the final struct looks like and which fields are mandatory.
1. Identify required and optional fields
Go through your struct and mark every field as mandatory (must appear in the constructor) or optional (can have a default). Fields that have no sensible default — like a time::Date — must be mandatory.
2. Create the builder struct
Write a separate struct that holds all fields, either directly or wrapped in Option. Keep it private if the builder is only meant to be used through its methods.
3. Write the constructor
The new (or custom-named) function accepts only the mandatory fields and sets all optional fields to their default values — usually None, 0, or an empty collection.
4. Add setter methods
Each optional field gets a method that takes self (or &mut self, depending on the style you chose), updates the field, and returns the builder. Use impl Into<T> for string-like types to improve ergonomics.
5. Implement the build method
The final method consumes the builder, validates the assembled values, and returns the target struct. If any mandatory field was missed earlier and the compiler can't catch it, this is where you panic! or return a Result with a clear error.
Common Mistakes
Builders feel simple until they interact with ownership rules.
Forgetting to reassign after a consuming setter. A conditional setter that takes mut self consumes the builder. If you don't assign the result back to the same variable, the original variable is gone. The fix is builder = builder.setter(value).
Calling build before all mandatory fields are set. If your builder doesn't use type-state, missing a required field compiles but panics at runtime. The safest approach is to make the final build method return a Result and use ? at the call site, rather than unwrap.
Attempting to reuse a builder after build. The build method consumes the builder. Once called, the builder is destroyed. If you need multiple instances, create a fresh builder each time or use a Clone implementation on the builder.
Don't silently ignore validation failures:
A build method that returns the struct directly forces the caller to handle failure through a panic. For libraries, prefer returning Result<MyType, BuildError> so that the caller can recover gracefully. A panic in library code is a bug for the downstream user.
When to Reach for a Builder
Not every struct needs a builder. For a type with two or three fields, a plain constructor function or a struct literal is clearer and shorter. A builder becomes worth the extra code when:
- There are four or more fields, especially with a mix of mandatory and optional ones.
- Several mandatory fields have no reasonable default, so the
Defaulttrait can't be derived. - Validation must happen at construction time (e.g.,
width > 0), and you want to consolidate that logic in one place. - The struct is part of a public API and you expect it to grow new optional fields over time — the builder shields callers from those additions.
Summary
The builder pattern is not about replacing struct literals; it is about moving complexity from the call site into a dedicated type that knows how to assemble a valid struct. When applied to types with many optional fields or strict construction invariants, a builder removes boilerplate, prevents accidentally uninitialized data, and keeps client code readable.
The two main styles — consuming and borrowing — each solve a different ergonomic problem, and the type-state variant pushes correctness checks entirely into the type system. Knowing when to use each one lets you write APIs that feel light to use but are hard to misuse.