rustdesign-patternsbest-practices

Builder Pattern in Rust: Constructing Complex Values Without a Dozen `new` Variants

ยท10 min read

Builder Pattern in Rust: Constructing Complex Values Without a Dozen new Variants

Every Rustacean eventually meets a struct that grew too many fields. What started as HttpRequest::new(url) sprouted a timeout, then headers, then a retry policy, then a body. Now you're staring at HttpRequest::new_with_headers_and_timeout(...) and wondering where it all went wrong.

The builder pattern is Rust's answer. Rust has no constructor overloading, so instead you build the value up piece by piece and finalize it with a single call. Done well, it reads like a small DSL. Done poorly, it turns into ceremony. This post walks through both.

The problem with constructors

Suppose you're modeling an HTTP request:

pub struct HttpRequest {
    url: String,
    method: String,
    timeout_secs: u64,
    headers: Vec<(String, String)>,
    body: Option<Vec<u8>>,
    follow_redirects: bool,
}

The naive approach is a new that takes everything:

impl HttpRequest {
    pub fn new(
        url: String,
        method: String,
        timeout_secs: u64,
        headers: Vec<(String, String)>,
        body: Option<Vec<u8>>,
        follow_redirects: bool,
    ) -> Self {
        Self { url, method, timeout_secs, headers, body, follow_redirects }
    }
}

Every caller now passes six arguments in a fixed order, and adding a seventh field is a breaking change. HttpRequest::new_with_body, new_with_headers, new_full - each is a small crime you'll pay for later.

A hand-written builder

The core idea is to introduce a companion struct that holds partial state, exposes chainable setters, and finalizes with build:

pub struct HttpRequestBuilder {
    url: String,
    method: String,
    timeout_secs: u64,
    headers: Vec<(String, String)>,
    body: Option<Vec<u8>>,
    follow_redirects: bool,
}
 
impl HttpRequestBuilder {
    pub fn new(url: impl Into<String>) -> Self {
        Self {
            url: url.into(),
            method: "GET".into(),
            timeout_secs: 30,
            headers: Vec::new(),
            body: None,
            follow_redirects: true,
        }
    }
 
    pub fn method(mut self, method: impl Into<String>) -> Self {
        self.method = method.into();
        self
    }
 
    pub fn timeout_secs(mut self, secs: u64) -> Self {
        self.timeout_secs = secs;
        self
    }
 
    pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
        self.headers.push((name.into(), value.into()));
        self
    }
 
    pub fn body(mut self, bytes: Vec<u8>) -> Self {
        self.body = Some(bytes);
        self
    }
 
    pub fn follow_redirects(mut self, follow: bool) -> Self {
        self.follow_redirects = follow;
        self
    }
 
    pub fn build(self) -> Result<HttpRequest, BuildError> {
        if self.url.is_empty() {
            return Err(BuildError::MissingUrl);
        }
        if self.timeout_secs == 0 {
            return Err(BuildError::InvalidTimeout);
        }
 
        Ok(HttpRequest {
            url: self.url,
            method: self.method,
            timeout_secs: self.timeout_secs,
            headers: self.headers,
            body: self.body,
            follow_redirects: self.follow_redirects,
        })
    }
}

BuildError is a plain enum with one variant per thing that can go wrong:

#[derive(Debug)]
pub enum BuildError {
    MissingUrl,
    InvalidTimeout,
}

Usage reads naturally at the call site:

let req = HttpRequestBuilder::new("https://api.example.com/v1/users")
    .method("POST")
    .timeout_secs(10)
    .header("Content-Type", "application/json")
    .body(payload)
    .build()?;

A few details are doing real work here.

Owning setters. Each setter takes mut self and returns Self. The builder moves through the chain, so callers cannot accidentally reuse a stale builder. The moves are cheap, and in optimized builds the compiler usually removes them entirely.

impl Into<String>. This lets callers pass a &str, a String, or anything else that converts. It buys the caller a bit of ergonomics at almost no complexity cost.

Sensible defaults in new. The builder starts in a valid-shaped state, and setters only override what the caller cares about. This is the single biggest ergonomic win of the pattern.

build returns Result. Validation lives in one place, near the type it protects, and callers surface failures with the ? operator like any other fallible call.

Owned versus borrowed builders

The version above consumes self on every call. That works for one-shot construction, but there is another shape worth knowing.

A &mut self builder mutates in place and lets callers keep the builder in a variable:

impl HttpRequestBuilder {
    pub fn method(&mut self, method: impl Into<String>) -> &mut Self {
        self.method = method.into();
        self
    }
 
    pub fn header(&mut self, name: impl Into<String>, value: impl Into<String>) -> &mut Self {
        self.headers.push((name.into(), value.into()));
        self
    }
 
    pub fn build(&self) -> HttpRequest {
        HttpRequest {
            url: self.url.clone(),
            method: self.method.clone(),
            timeout_secs: self.timeout_secs,
            headers: self.headers.clone(),
        }
    }
}

This is useful when you want to build conditionally:

let mut builder = HttpRequestBuilder::new(url);
builder.timeout_secs(10);
if authenticated {
    builder.header("Authorization", token);
}
let req = builder.build();

The trade-off is in build. A chain like HttpRequestBuilder::new(url).method("DELETE").build() ends with a &mut Self, so build can't take self by value - there is nothing to move out of. It has to take &self and clone the fields, as above. That costs a few allocations, and in return the same builder can produce several values.

The owning variant (mut self returning Self) is the more common shape in the ecosystem because it composes cleanly in a single chained expression. Reach for the &mut self form when conditional configuration is the norm.

Option fields versus defaults

Two design questions come up as your builder grows: what should absent mean, and where does the default live?

If a field has a genuine default (a GET method, a 30-second timeout), store it as the plain type on both the builder and the final struct, and seed it in Builder::new. Callers who don't care never touch it, and the final struct is trivial to consume.

If a field is genuinely optional - a body, a proxy, a retry policy - store it as Option<T> on both sides. The setter takes the plain value and wraps it for the caller:

pub fn proxy(mut self, url: impl Into<String>) -> Self {
    self.proxy = Some(url.into());
    self
}

Do not stack Option<Option<T>> inside the builder just to distinguish "not set" from "set to None". That distinction almost never earns its keep, and it makes build harder to reason about.

Typestate builders: catching missing fields at compile time

Returning Result from build is fine, but it pushes the check to runtime. If a field is truly required - a URL for a request, a filename for a file handle - you can encode that requirement in the type system so calling build without it fails to compile.

The trick is to parameterize the builder over marker types that track which required fields have been set:

use std::marker::PhantomData;
 
pub struct Unset;
pub struct Set;
 
pub struct HttpRequestBuilder<U> {
    url: Option<String>,
    method: String,
    timeout_secs: u64,
    _url_state: PhantomData<U>,
}
 
impl HttpRequestBuilder<Unset> {
    pub fn new() -> Self {
        Self {
            url: None,
            method: "GET".into(),
            timeout_secs: 30,
            _url_state: PhantomData,
        }
    }
 
    pub fn url(self, url: impl Into<String>) -> HttpRequestBuilder<Set> {
        HttpRequestBuilder {
            url: Some(url.into()),
            method: self.method,
            timeout_secs: self.timeout_secs,
            _url_state: PhantomData,
        }
    }
}
 
impl HttpRequestBuilder<Set> {
    pub fn build(self) -> HttpRequest {
        HttpRequest {
            url: self.url.expect("Set state guarantees url is Some"),
            method: self.method,
            timeout_secs: self.timeout_secs,
        }
    }
}

Setters for optional fields go in an impl<U> block, so they work in either state and keep the state they were called in:

impl<U> HttpRequestBuilder<U> {
    pub fn timeout_secs(mut self, secs: u64) -> Self {
        self.timeout_secs = secs;
        self
    }
}

Now HttpRequestBuilder::new().timeout_secs(5).build() won't compile, because build is defined only on HttpRequestBuilder<Set>:

PhantomData is what lets the marker types travel with the builder without occupying any runtime space; the whole thing is a zero-cost check the compiler performs on your behalf. The .expect inside build is not a shortcut - the type parameter has already proved the invariant, so the panic branch is unreachable in practice.

Typestate builders are wonderful when they fit. They also multiply implementation code by the number of required fields, so save them for values where forgetting a field would be really costly - cryptographic keys, database connections, anything where "compiled and ran, panicked at midnight" is unacceptable.

derive_builder: the classic macro approach

Writing all of that by hand for every struct gets old. The derive_builder crate generates a builder from a #[derive]:

use derive_builder::Builder;
 
#[derive(Debug, Builder)]
#[builder(build_fn(error = "BuildError"))]
pub struct HttpRequest {
    #[builder(setter(into))]
    url: String,
    #[builder(setter(into), default = "\"GET\".into()")]
    method: String,
    #[builder(default = "30")]
    timeout_secs: u64,
    #[builder(default)]
    headers: Vec<(String, String)>,
    #[builder(default)]
    body: Option<Vec<u8>>,
    #[builder(default = "true")]
    follow_redirects: bool,
}

A custom error type has to be constructible from the error derive_builder raises when a required field is missing:

use derive_builder::UninitializedFieldError;
 
#[derive(Debug)]
pub enum BuildError {
    MissingField(&'static str),
}
 
impl From<UninitializedFieldError> for BuildError {
    fn from(err: UninitializedFieldError) -> Self {
        BuildError::MissingField(err.field_name())
    }
}

You get HttpRequestBuilder::default(), .url(...), .method(...), and a build() returning Result<HttpRequest, BuildError> for free. Setters return &mut Self by default; annotate the struct with #[builder(pattern = "owned")] if you prefer the owning form.

The strength of derive_builder is that it is flexible and well established. The weakness is that missing required fields are runtime errors. Forget the url and this still compiles:

let result = HttpRequestBuilder::default().method("POST").build();
println!("{result:?}");

You only find out when it runs:

bon: typestate builders without the boilerplate

bon is the newer entrant, and its central pitch is that required fields become compile-time checks:

use bon::Builder;
 
#[derive(Debug, Builder)]
pub struct HttpRequest {
    url: String,
    #[builder(default = "GET".to_string())]
    method: String,
    #[builder(default = 30)]
    timeout_secs: u64,
    #[builder(default)]
    headers: Vec<(String, String)>,
    body: Option<Vec<u8>>,
    #[builder(default = true)]
    follow_redirects: bool,
}
 
let req = HttpRequest::builder()
    .url("https://api.example.com/v1/users".to_string())
    .method("POST".to_string())
    .body(payload)
    .build();

build() here returns HttpRequest directly - not a Result - because bon generates a typestate builder under the hood. Option<T> fields become optional in the builder without a manual default annotation, which is a small but real quality-of-life win.

Forget url and the code fails to compile. The error is noisier than the hand-written version because it points into generated code, but the key line is at the top: the member url was not set.

The trade-off is expressiveness: bon is opinionated, and complex validation still has to live somewhere else (a TryFrom on the built value, for instance). Like any proc macro, it also adds some compile time compared to the hand-written form.

Choosing between them

A rough guide:

  • Hand-written builder. Use it when the type is central to your crate's public API, when you want full control over documentation and error messages, or when the setters have logic beyond "assign this field". The cost is real code to maintain.
  • derive_builder. Use it when you want a builder quickly, don't mind runtime validation, and value the crate's maturity. It's the safe default for internal types.
  • bon. Use it when required fields matter enough that you want compile-time enforcement, and the struct's shape is straightforward. It's the strongest choice for library types where forgetting a field is a bug worth catching before the tests run.
  • Typestate by hand. Reserve it for the highest-stakes types in the codebase, or when you're writing an example to teach the pattern.

None of these are wrong. They sit on a spectrum from minimal ceremony and maximum flexibility to maximum safety generated for you. Pick the point that matches how the type will be used, and revisit the choice if the type's requirements shift.

Wrapping up

The builder pattern isn't magic - it's a companion struct, a chain of setters, and a build method that either returns the value or explains why it can't. Once you have that shape in your head, deciding between owned and borrowed setters, Option fields and defaults, or derive_builder and bon becomes a series of small trade-offs rather than a design crisis. Practice it on your next struct with more than three configuration knobs, and it will start to feel like the natural way to construct anything non-trivial in Rust.

Subscribe to our newsletter

Get the latest updates on courses, features, tools, and resources about Rust.

Ferris the Rust crab

Learn Rust by Practice

Master Rust through hands-on coding exercises and real-world examples.

Get Started