🎁 Give the #1 gift request of 2024... a ZTM membership gift card! 🎁

Creating Structs In Rust: Builder Pattern, Fluent Interfaces, And More

Jayson Lennon
Jayson Lennon
hero image

Rust has a lot of benefits.

But unlike some other programming languages, Rust doesn't have either:

  • Constructors, or
  • Automatic defaults for newly created structures

This means that if you want to get the most from this language, you'll need to figure out a way to create a new instance of a structure in the most ergonomic way possible.

Don't worry though. In this guide, we'll explore different techniques and patterns available for instantiating structures in the Rust programming language.

Sidenote: If you struggle with either understanding or applying these techniques, want to learn more, or simply want a deep dive into learning Rust (regardless of current level), then check out my complete Rust course.

learn rust

Learn how to code and build your own real-world applications using Rust so that you can get hired this year. No previous programming or Rust experience needed.

You can check out the course here, or watch the first few videos for free.

Better still. You also get direct access to me, other Rust students, and working Rust developers via our private Discord channel! You can ask questions, get feedback and more.

Anyways, with that out of the way, let's get into this guide.

A plain `new` function

We'll start with the technique that takes the fewest amount of steps and is the quickest to write, which is implementing a new function.

Using a new function has two major benefits:

  1. The code is clear, and
  2. The return type can be Result<T> to signal possible instantiation errors

Let's take a look at a sample new function below:

struct User {
    name: String,
}

impl User {
    // Here's a `new` function to make a new `User`.
    // The generics allow using `String` or `&str` as an argument.
    pub fn new<S: Into<String>>(name: S) -> Self {
        Self { name: name.into() }
    }
}

let alice = User::new("Alice");
let bob = User::new(String::from("Bob"));

There's not much happening here, but let's break it down anyway.

The new function has a name parameter, creates a new User having name, and then returns the User.

Simple! But what about when you need to support situations where instantiation might fail?

Well, then you can change the return type to Result, like so:

struct User {
    name: String,
    // Now we have an email
    email: String,
}

impl User {
    pub fn new<S: Into<String>(name: S, email: S) -> Result<Self, &'static str> {
        let email = email.into();
        // The email might be malformed, so let's check it with an imaginary validation function
        if email_is_ok(&email) {
            Ok(Self {
                name: name.into(),
                email,
            })
        } else {
            Err("malformed email")
        }
    }
}

let alice = User::new("Alice", "alice@example.com")?;

Can you see the problem here?

Using a new function works great for small structures, but it doesn't scale well. Each time another field gets added to User, we need to add a new parameter to the new function.

too many new parameters

It will eventually reach a point where the function will have too many parameters, making it problematic to work with:

// changing requirements demand an authorization level
enum AuthLevel {
    Admin,
    Guest,
    User,
}
struct User {
    name: String,
    email: String,
    // more fields!
    auth_level: AuthLevel,
    address: Option<String>,
    phone: Option<String>,
}

impl User {
    // function signature isn't _too_ bad, but it does take extra effort to comprehend
    pub fn new<S: Into<String>>(
        name: S,
        email: S,
        auth_level: AuthLevel,
        address: Option<S>,
        phone: Option<S>,
    ) -> Result<Self, &'static str> {
        todo!()
    }
}

// calling `new` is becoming too verbose
let alice = User::new(
    "Alice",
    "alice@example.com",
    AuthLevel::User,
    Some("+1-321-555-1234"),
    None,
);

Also, you might have noticed that the phone and address got swapped, and now those fields in the structures will have incorrect data.

swapped data

Not great, and yet another reason why it's a good idea to avoid a large number of function parameters.

So what's the solution?

Well, it's pretty simple. Introducing new types both fixes the swapped data problem, and allows us to make a new User without Result.

Better still, it makes calling the function even more verbose than it already is:

impl User {
    pub fn new(
        name: Name,
        email: Email,
        auth_level: AuthLevel,
        address: Option<Address>,
        phone: Option<Phone>,
    ) -> Self {
        todo!()
    }
}

let alice = User::new(
    Name::new("Alice"),
    Email::new("alice@example.com")?,
    AuthLevel::User,
    None,
    Some(Phone::new("+1-321-555-1234")),
);

Now, although this solution works, it still isn't ideal and has a few issues.

Sure, we could move the instantiation of the new types to individual variable bindings, but that would double the lines of code.

doubling the code vs making it more efficient

And so before trying to make this more ergonomic, we should first take a look at what other techniques are available for instantiating structures...

Fluent interface

Fluent interfaces combine method chaining with English prose to create an API that reads naturally.

To enable method chaining in Rust, we need to implement functions that return Self.

This will expose the implemented methods on the structure and we can continually call whichever methods we need.

For example

Let's start with a structure for configuring an HttpRequest:

// Boilerplate-y stuff
struct Cookie;
enum HttpMethod {
    Get,
    Post,
    Put,
}

// Struct we will use to implement the fluent interface
struct HttpRequest {
    address: String,
    method: HttpMethod,
    cookies: Vec<Cookie>,
    timeout: std::time::Duration,
    body: Vec<u8>,
}

// The important bits
impl HttpRequest {
    // `address` is required
    pub fn new<S: Into<String>>(address: S) -> Self {
        Self {
            address: address.into(),
            // set the defaults for everything else
            method: HttpMethod::Get,
            cookies: vec![],
            timeout: std::time::Duration::from_secs(5),
            body: vec![],
        }
    }
}

Since new returns Self, we can begin calling more methods implemented on HttpRequest using method syntax.

Important: While we can technically name these methods anything we want, to make the interface fluent we should prefix the names with transitive verbs or

prepositions, as doing so helps to make the method chaining read more fluently.

We'll start by using with_ as the prefix on the method names:

impl HttpRequest {
    // Consume `self` and then return `Self`. This enables method chaining
    // because the return value is also `HttpRequest`, which implements these
    // methods.
    pub fn with_body(self, body: Vec<u8>) -> Self {
        // since we own self, we can make it mutable:
        let mut req = self;
        // set the body:
        req.body = body;
        // move `req` back out of the method:
        req
    }
    pub fn with_method(self, method: HttpMethod) -> Self {
        let mut req = self;
        req.method = method;
        req
    }
    pub fn with_timeout(self, timeout: std::time::Duration) -> Self {
        let mut req = self;
        req.timeout = timeout;
        req
    }
}

Other good candidates for names in a fluent interface are using_, use_, and include_.

I'll use add_ for adding cookies to the cookies field:

impl HttpRequest {
    pub fn add_cookie(self, cookie: Cookie) -> Self {
        let mut req = self;
        req.cookies.push(cookie);
        req
    }
}

Now that we have implemented a fluent interface, we can use it like this:

let request = HttpRequest::new("example.com")
    .with_method(HttpMethod::Post)
    .add_cookie(Cookie)
    .add_cookie(Cookie)
    .with_body("sample".into());

Since the initial call to new creates an HttpRequest to work with, implementing a fluent interface is more suitable when the fields have reasonable defaults, so let's take a look at them.

Default

When your data has default values for every field, we can use the Default trait from the standard library.

Sidenote: Default allows setting default values to the fields of a structure. However, Rust doesn't implement this automatically, but we can derive it.

To show how Default works, we'll create a Color structure which consists of red, green, and blue color components:

// automatically implement Default
#[derive(Default)]
struct Color {
    pub r: u8,
    pub g: u8,
    pub b: u8,
}

// the default for a `u8` is 0
let black = Color::default();

This is fine if we don't mind setting all the fields to 0, but if we introduce an alpha channel to control transparency, then we should implement Default manually.

Doing so allows us to set the default alpha channel to 255, which is fully opaque:

struct Color {
    pub r: u8,
    pub g: u8,
    pub b: u8,
    pub a: u8,
}

// implement Default manually
impl Default for Color {
    fn default() -> Self {
        Self {
            r: 0,
            g: 0,
            b: 0,
            a: 255,  // now we can see the color!
        }
    }
}

Using Default works great when all the fields of your struct have reasonable default values.

When combined with the technique in the next section, it offers an ergonomic way to create structures having different initial values.

Struct update syntax

Rust's struct update syntax enables us to take the fields from one instance of a structure and apply them to the fields of another structure.

Doing this reduces the number of steps needed to make copies of something where each copy has a slight alteration.

Using the Color example from the previous section:

// partially transparent black
let half_transparent = Color {
    r: 0,
    g: 0,
    b: 0,
    a: 127,
};

let transparent_red = Color {
    r: 255,
    // use `struct update` (..) to set the other fields
    // to the same value as `half_transparent`
    ..half_transparent
};

Struct update syntax also works with the Default trait:

let green = Color {
    g: 255,
    // set the other fields to default values
    ..Default::default()
};

The downside with struct update syntax is it works only when all of the fields are public.

Struct update automatically expands and sets the fields, so if they are private we won't have access to set them and we'll get a compiler error.

Dedicated default structures

Default is great, but not all structures have default values that make sense for every field. (Or perhaps we do have good defaults, but want to keep some fields private).

In either case, struct update won't work, but there is a solution. We can instead create a new structure containing all the public and default options.

For example

Let's create a Primitive structure for use in 3d modeling:

struct Primitive {
    model: Model,
    material: Option<Material>,
    position: Vec3,
    rotation: Vec3,
    scale: Vec3,
}

We could derive Default here, but I'd like to set the initial Model when creating the Primitive, and so Default won't work in this case.

Instead, we can create a Config structure and move all the values that do have sensible defaults to this Config structure, and then derive Default:

#[derive(Default)]
struct PrimitiveConfig {
    material: Option<Material>,
    position: Vec3,
    rotation: Vec3,
    scale: Vec3,
}

struct Primitive {
    model: Model,
    config: PrimitiveConfig,
}

First we will implement some functions to create a new Primitive:

impl Primitive {
    // make a new Primitive with the given config
    pub fn new(model: Model, config: PrimitiveConfig) -> Self {
        Self { model, config }
    }

    // make a new Primitive using the default config
    pub fn new_with_default(model: Model) -> Self {
        Self {
            model,
            config: Self::default_config(),
        }
    }
}

We should also implement a function to get the configuration structure with all defaults set.

Why?

Well, implementing this directly on Primitive makes it easier to work with the PrimitiveConfig since we don't need to include any extra use statements:

impl Primitive
    // return a fresh default config
    pub fn default_config() -> PrimitiveConfig {
        PrimitiveConfig::default()
    }
}

We could implement a fluent interface on PrimitiveConfig to set the fields for configuring the Primitive. However, we won't be able to use method chaining when we use Primitive::new().

Instead, we can implement a fluent interface on Primitive and have it update the configuration for us:

impl Primitive {
    pub fn with_position(self, position: Vec3) -> Self {
        let mut obj = self;
        obj.config.position = position;
        obj
    }

    pub fn with_scale(self, scale: Vec3) -> Self {
        let mut obj = self;
        obj.config.scale = scale;
        obj
    }

    pub fn with_rotation(self, rotation: Vec3) -> Self {
        let mut obj = self;
        obj.config.rotation = scale;
        obj
    }
}

With this functionality implemented, we now have access to some different ways to create a new Primitive.

Using the fluent interface looks like this:

// new Sphere using defaults
let sphere = Primitive::new_with_default(Sphere);

// new Sphere using defaults with modified position and scale
let sphere = Primitive::new_with_default(Sphere)
    .with_position(Vec3(10.0, 150.0, 0.0))
    .with_scale(Vec3(2.0, 2.0, 2.0));

If we want to make heavy modifications to the configuration, we can create one ahead of time and use that with the new function:

// make a config struct with lots of changes
let sphere_config = PrimitiveConfig {
    position: Vec3(100.0, 50.0, 0.0),
    scale: Vec3(2.0, 2.0, 2.0),
    material: Material(Stone),
    // use defaults for everything else
    ..Default::default()
};

// new Sphere using config from above
let sphere = Primitive::new(Sphere, sphere_config);

The configuration can get contained within a single expression by inlining it's creation in the Primitive::new() call:

// new Sphere with inline config
let sphere = Primitive::new(
    Sphere,
    PrimitiveConfig {
        position: Vec3(100.0, 50.0, 0.0),
        // use default material, rotation, and scale
        ..Default::default()
    },
);

And we can also copy configuration from another Primitive:

let cube = Primitive::new(
    Cube,
    PrimitiveConfig {
        scale: Vec3(5.0, 5.0, 5.0),
        // use the sphere settings for everything else
        ..sphere.config
    },
);

Combining the Default trait with a fluent interface offers a convenient way to create structures.

They are most useful when the structure consists of fields that have sensible defaults. In situations where there are many fields with no reasonable default values, we will need to use another approach.

Builder pattern

In the fluent interface example, we created a new structure right away and then used implemented methods to set the fields.

This works fine when most of the fields have a reasonable default. but if we have a structure containing many fields without defaults, then the fluent interface can't get implemented directly on the structure.

We first would need to set the non-default fields in the new function and this leads to the verbose API, which we saw at the beginning of this guide.

Instead we'll implement the fluent interface on a dedicated "builder" structure and then incrementally "build" the structure by setting one field at a time.

Why do this?

Well, the builder structure contains all the same fields as the target structure, but with two main differences:

  • All fields without a Default implementation get wrapped in Option
  • We always derive Default

This enables us to instantiate the builder structure and then set the required values (and possibly override the defaults) using the fluent interface.

Once we have set the values, we then call a "build" method which returns the target structure with all the fields set.

For example

Let's apply this to the User structure from the beginning of this post, since it has some fields without defaults:

// the User struct, for reference
struct User {
    name: String,
    email: String,
    auth_level: AuthLevel,
    address: Option<String>,
    phone: Option<String>,
}

impl User {
    // convenience function to get a new builder
    pub fn builder() -> UserBuilder {
        UserBuilder::default()
    }
}

// the "builder" struct
#[derive(Default)]
struct UserBuilder {
    name: Option<String>,
    email: Option<String>,
    auth_level: AuthLevel,
    address: Option<String>,
    phone: Option<String>,
}

// We need to implement `Default` on `AuthLevel` so we can
// derive `Default` on `UserBuilder`. Using `Guest` is a
// sensible default.
impl Default for AuthLevel {
    fn default() -> Self {
        AuthLevel::Guest
    }
}

Now we can implement the builder methods on UserBuilder. However, instead of returning Self like we did in the previous sections, we will return &mut Self.

This allows us to continually change the values without making copies of the builder. (Either way works fine with a builder).

A quick note on naming:

  • For the method names, I chose to duplicate the field names but you can use a with_ prefix (or something similar) to create a fluent interface
  • The method bodies are trivial, so don't worry about them. Set the field in self to whatever gets passed into the method

Here's the implementation:

impl UserBuilder {
    pub fn name<S: Into<String>>(&mut self, name: S) -> &mut Self {
        self.name = Some(name.into());
        self
    }
    pub fn email<S: Into<String>>(&mut self, email: S) -> &mut Self {
        self.email = Some(email.into());
        self
    }
    // .. snip ..
    // the remaining fields follow the same pattern
    // .. snip ..

    // Creates the target User structure
    pub fn build(self) -> Result<User, &'static str> {
        let name = self.name.ok_or("must have a name")?;
        let email = self.email.ok_or("must have an email")?;
        Ok(User {
            name,
            email,
            auth_level: self.AuthLevel,
            address: self.address,
            phone: self.phone,
        })
    }
}

We also created a build method which instantiates the User struct.

(It returns a Result because it needs to verify that all the necessary fields were populated).

This is the difference between the fluent interface from the previous sections and the builder pattern. Builders can verify that the fields have appropriate data, before returning the target structure.

Pretty helpful right?

To use the builder:

let mut alice = User::builder();
alice
    .name("Alice")
    .email("alice@example.com")
    .auth_level(AuthLevel::User)
    .phone("+1-321-555-1234");
// optional type annotations show the type we get back from .build()
let alice: Result<User, &'static str> = alice.build()?;

Important: Although this technique works for all structures regardless of the number of fields that have do (or don't) have defaults, it does have a disadvantage.

Since the fields get checked at runtime, there is runtime overhead to using a builder. It won't always be an issue, but if you are using a builder in a hot loop, then there may be a reduction in performance, so keep it in mind.

Typed builder

A typed builder combines the type state pattern with the builder pattern.

This gives us a builder that gets checked for errors at compile time instead of at runtime, making the typed builder a zero-cost abstraction.

A quick note on the Type state pattern in Rust

  • A state machine gets encoded into the Rust type system using structures
  • Each structure represents a single state in the state machine
  • State transitions get performed by implementing a function on the structures (states) which returns the next state
  • The Rust compiler checks the return types of the transition functions and enforces correct transitions (an incorrect transition is a compiler error)

For a more detailed breakdown of how this works, check out my guide on the type state pattern in Rust.

Anyways, back to creating structs...

Builders need to set some (or all) fields using implemented methods, so the states we need for each field are:

  • field is set, or
  • field is not set

We'll get started with a trimmed-down User structure using the name and email fields:

struct User {
    name: String,
    email: String,
}

Since there are two fields, we need 4 states: a "set" state and "not set" state for both name and email.

We'll make each "set" state a new type which will contain the data needed by User:

// states for the `name` field
struct Name(String);
struct NoName;

// states for the `email` field
struct Email(String);
struct NoEmail;

Next we'll create a generic builder structure:

struct UserBuilder<N, E> {
    name: N,
    email: E,
}

The UserBuilder structure is generic over N and E:

  • N is the current state of the name field
  • E is the current state of the email field

The idea with this structure is to set the generic parameters to the current state of the building process.

Here are some type annotations showing all possible states of the builder:

let no_fields_set:    UserBuilder<NoName, NoEmail>;
let email_field_set:  UserBuilder<NoName, Email>;
let name_field_set:   UserBuilder<Name, NoEmail>;
let both_fields_set:  UserBuilder<Name, Email>;

If we were to implement functionality on UserBuilder<NoName, NoEmail> then the function we write will be available exclusively when we have not set any fields.

This is the perfect spot for a new function because we will start the state machine with both fields in the "not set" state:

impl UserBuilder<NoName, NoEmail> {
    pub fn new() -> Self {
        Self {
            name: NoName,
            email: NoEmail,
        }
    }
}

Now comes the fun part!

We need to write impl blocks for the remaining combinations of states, however, when we create these implementation blocks, we may transition the state for only a single field at a time.

For example

When setting the value of the email field, the state needs to transition from NoEmail to Email. This means we want to write our implementation block to ignore the name field.

We will use the generic parameter N to represent the state of the name field. (N can be any state and we don't care what the state is).

Also, this implementation block is only for managing the state of the email field:

// In our example, this applies to:
//   - UserBuilder<Name, NoEmail>
//   - UserBuilder<NoName, NoEmail>
// And also applies to:
//   - UserBuilder<N, NoEmail>
// Where `N` is anything
//
//                     ┌── Transition from `NoEmail` to `Email` ────┐
//                     │                                            │
impl<N> UserBuilder<N, NoEmail> { //                                │
    //              │                                               │
    //              └─ N remains unchanged ──────────────────────┐  │
    //                                                           │  │
    pub fn email<S: Into<String>>(self, email: S) -> UserBuilder<N, Email> {
        UserBuilder {
            // We move whatever state was in the `name` field to the new
            // `UserBuilder`.
            name: self.name,
            // Change the state from `NoEmail` to `Email`. Since it's
            // a new type, we include the `email` as a String.
            email: Email(email.into()),
        }
    }
}

To implement the state transition for name, we can copy the above block and change the generic parameters and fields accordingly:

//                  ┌── Transition from `NoName` to `Name` ────┐
//                  │                                          │
impl<E> UserBuilder<NoName, E> { //                            │
    pub fn name<S: Into<String>>(self, name: S) -> UserBuilder<Name, E> {
        UserBuilder {
            name: Name(name.into()),
            email: self.email,
        }
    }
}

Now that we have methods to set the name and email fields, we can write the build method to construct a User whenever the UserBuilder has both the name and email fields in the "set" state.

The "set" states are the Name and Email structures, so the implementation block must use UserBuilder<Name, Email>:

// `Name` and `Email` are the "set" states. The `build` method is
// available solely when the builder is at this state.
impl UserBuilder<Name, Email> {
    pub fn build(self) -> User {
        User {
            // The states are tuple structs, using
            // .0 accesses the inner String.
            name: self.name.0,
            email: self.email.0,
        }
    }
}

After implementing a convenience function on the User struct, we can use the typed builder like we did with the builder in the previous section:

impl User {
    // Create a builder having the states `NoName` and `NoEmail`
    pub fn builder() -> UserBuilder<NoName, NoEmail> {
        UserBuilder::new()
    }
}

// optional type annotation shows the type we get back from .build()
let alice: User = User::builder()
    .name("Alice")
    .email("alice@example.com")
    .build();

Sidenote: With the typed builder implemented, if we forget to call .name() or .email(), then we will get a compiler error.

However, we will also get a compiler error if we call these functions more than a single time.

In either instance we'll get an error that looks something like this:

typed builder error

As you will agree, this isn't the most helpful error, but we can fix that!

The typed_builder crate

Now that we have seen how the typed builder pattern works, let's jump straight to the typed_builder crate.

This crate uses procedural macros to:

  1. Automatically generate all the code in the previous section
  2. Show helpful error messages if we use the builder incorrectly, and
  3. Allows you to relax because you don't have to write anything from the previous section

Since we don't need to write any of the implementation blocks, let's go back to using the original User struct with all it's fields:

struct User {
    name: String,
    email: String,
    auth_level: AuthLevel,
    address: Option<String>,
    phone: Option<String>,
}

The typed_builder crate provides field annotations that we can use to customize how the impl blocks get generated.

This is great because we want to set some defaults and apply the <S: Into<String>> that existed on our original builder implementation, like so:

#[derive(Debug, TypedBuilder)]
struct User {
    // Use <S: Into<String>>
    #[builder(setter(into))]
    name: String,
    #[builder(setter(into))]
    email: String,

    // Sets the default to Guest
    #[builder(default = AuthLevel::Guest)]
    auth_level: AuthLevel,

    // - Use `default` trait (`None` for an `Option`)
    // - Allow calling the builder using "address" instead of `Some("address")`
    // - Use <S: Into<String>>
    #[builder(default, setter(strip_option, into))]
    address: Option<String>,
    #[builder(default, setter(strip_option, into))]
    phone: Option<String>,
}

And we're done!

let alice = User::builder()
    .name("Alice")
    .email("alice@example.com")
    .auth_level(AuthLevel::User)
    .phone("+1-321-555-1234")
    .build();

If we forget to set one of the fields, such as forgetting .email(), we'll get a helpful error message instead of the ambiguous "method not found" :

Helpful typed builder error

Go add structures into your own code!

Hopefully these 4 methods have helped to clear up any questions you have on the different ways to instantiate a new structure in Rust.

Each of these techniques have their place in different situations, so go ahead and test them out in your own projects.

And remember. If you struggle with applying these techniques and want to learn more, or simply want a deep dive into learning Rust (regardless of current skill level), then check out my complete Rust course.

You can learn all about the course here, or watch the first few videos for free.

What do you have to lose? Dive in, learn everything about structures in Rust (and more), and then ask questions in the private Discord server!

BONUS: More Rust tutorials, guides & resources

If you've made it this far, you're clearly interested in Rust so definitely check out all of my Rust posts and content:

More from Zero To Mastery

53 Rust Interview Questions + Answers (Easy, Medium, Hard) preview
53 Rust Interview Questions + Answers (Easy, Medium, Hard)

Are you ready for your Rust interview? Try out these 53 Rust programming interview questions to find out. Or use them as practice questions to help you prepare!

How Strings Work In Rust preview
How Strings Work In Rust

You can't use Rust without coming across its multiple string types. But what are they for and why does it use them? Learn this and more in this Rust tutorial.

Top 15 Rust Projects To Elevate Your Skills preview
Top 15 Rust Projects To Elevate Your Skills

From beginner to advanced, these are the best Rust projects to push your skills, grow your confidence, and wow potential employers. Check them out now!