Skip to content
C Codeloom
Rust

Rust Builder Pattern Explained

Learn the builder pattern in Rust, why it fits the language so well, and how to use it for ergonomic, type-safe configuration of complex structs.

·4 min read · By Codeloom
Intermediate 9 min read

What you'll learn

  • Core concept introduced
  • How the API is structured
  • Typical idiomatic usage
  • Common pitfalls to avoid
  • When and where to apply it

Prerequisites

  • Basic Rust familiarity

What and Why

The builder pattern solves a problem Rust feels acutely: constructors. Rust has no function overloading and no named arguments. If a struct has 12 optional fields, writing Foo::new(a, b, c, d, e, f, g, h, i, j, k, l) is painful and error-prone.

A builder fixes this by giving each field a fluent setter method. You chain calls like Foo::builder().host("localhost").port(8080).build(), and the final build() produces the configured value. The pattern shows up everywhere in the Rust ecosystem - reqwest::Client::builder(), tokio::runtime::Builder, std::process::Command, and std::thread::Builder are all examples.

Mental Model

Think of a builder as a mutable scratch struct that mirrors the target struct, but with every field optional. You poke values into it through setters, and build() validates and finalizes the result. There are three flavors worth knowing.

The first is the owned builder, where each setter takes self by value and returns Self. This enables fluent chaining and lets you move non-Copy types in cheaply. The second is the mutable-reference builder, where setters take &mut self. This is friendlier when you want to set fields conditionally across multiple statements. The third is the typestate builder, which uses generic type parameters to track which required fields have been set, making invalid configurations a compile error.

Hands-on Example

Here is a small owned builder with a required field tracked via typestate.

struct Unset;
struct Set<T>(T);

struct ConnectionBuilder<H> {
    host: H,
    port: u16,
    tls: bool,
}

impl ConnectionBuilder<Unset> {
    fn new() -> Self { Self { host: Unset, port: 80, tls: false } }
    fn host(self, h: String) -> ConnectionBuilder<Set<String>> {
        ConnectionBuilder { host: Set(h), port: self.port, tls: self.tls }
    }
}

impl<H> ConnectionBuilder<H> {
    fn port(mut self, p: u16) -> Self { self.port = p; self }
    fn tls(mut self, t: bool) -> Self { self.tls = t; self }
}

impl ConnectionBuilder<Set<String>> {
    fn build(self) -> Connection {
        Connection { host: self.host.0, port: self.port, tls: self.tls }
    }
}

struct Connection { host: String, port: u16, tls: bool }
Typestate builder transitions until build is callable

The compiler refuses to call build() until host has been set, because build only exists on the Set<String> variant.

Common Pitfalls

A frequent pitfall is mixing owned and borrowed builder styles in one type, which confuses callers. Pick one style per builder and stick with it.

Another mistake is hiding required fields behind defaults. If port defaults to 0, you’ve silently allowed broken configurations. Use typestate, or do validation in build() and return Result.

Watch out for Clone traps: if you implement an owned builder for a struct that holds non-Clone resources, chained setters work, but storing the partially built value in a Vec won’t. Document this clearly.

Finally, don’t reach for derive macros like derive_builder too early. They are great for boilerplate but can mask design issues that a hand-written builder would force you to confront.

Practical Tips

For public APIs, prefer the owned style with self-by-value setters. It composes cleanly with method chaining and avoids lifetime puzzles. Add a Default impl on the builder so users can start with Builder::default().

When a field is conditionally optional, use Option<T> in the builder and unwrap into a sensible default inside build(). If build() can fail, return Result<T, BuildError> and define a clear error enum.

For internal types with many configuration knobs, consider bon, typed-builder, or derive_builder. These generate compile-time-safe builders with minimal noise, and most support the typestate pattern out of the box.

Wrap-up

Builders aren’t just sugar - they’re a Rust idiom that compensates for the absence of named arguments and optional parameters. Whether you write them by hand or generate them with a macro, aim for fluent setters, an explicit build() step, and compile-time guarantees that prevent misuse. Your future users will thank you.