Support Ukraine. DONATE.
A blog about software development.

Builder pattern in Rust

Serhii Potapov October 19, 2021 #rust #patterns

As you know, Rust does not support optional function arguments nor keyword arguments, nor function overloading. To overcome this limitation rust developers frequently apply builder pattern. It requires some extra coding, but from the API ergonomics perspective, gives a similar effect as keyword arguments and optional arguments.

Introduction to problem

Consider the following rust structure:

struct User {
    email: Option<String>,
    first_name: Option<String>,
    last_name: Option<String>
}

In Ruby, a class that holds the same data can be defined as:

class User
  attr_reader :email, :first_name, :last_name

  def initialize(email: nil, first_name: nil, last_name: nil)
    @email = email
    @first_name = first_name
    @last_name = last_name
  end
end

Don't worry much about Ruby, I just want you to show how easily a user can be created by explicitly specifying relevant fields:

greyblake = User.new(
  email: "greyblake@example.com",
  first_name: "Serhii",
)

last_name is not there, so it gets the default value nil automatically.

Initializing a structure in Rust

Since we do not have default arguments in Rust, in order to initialize such structure we would have to list all fields:

let greyblake = User {
    email: Some("example@example.com".to_string()),
    first_name: Some("Serhii".to_string()),
    last_name: None,
}

This is quite similar to Ruby's keyword arguments, but we have to set all fields although last_name is None. It works well, but for big complex structures, it can be verbose and annoying.

Alternatively we can implement a new() constructor:

impl User {
    fn new(
        email: Option<String>,
        first_name: Option<String>,
        last_name: Option<String>
    ) -> Self {
        Self { email, first_name, last_name }
    }
}

Which will be used in the following way:

let greyblake = User::new(
    Some("example@example.com".to_string()),
    Some("Serhii".to_string()),
    None
)

But it became even worse: we still have to list values for all the fields, but now it's much easier to screw up by passing values in the wrong order (yeah, the newtype technique could help us here, but this article is not about that 🐻).

The Builder pattern to rescue

A builder is an extra structure, that provides an ergonomic interface to set values and a method to build a target structure. Let's implement UserBuilder that helps us to build User:

struct UserBuilder {
    email: Option<String>,
    first_name: Option<String>,
    last_name: Option<String>
}

impl UserBuilder {
    fn new() -> Self {
        Self {
            email: None,
            first_name: None,
            last_name: None,
        }
    }

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

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

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

    fn build(self) -> User {
        let Self { email, first_name, last_name } = self;
        User { email, first_name, last_name }
    }
}

The things worth noticing:

Usually for convenience User would implement builder() associated function, so UserBuilder does not have to be imported explicitly:

impl User {
    fn builder() -> UserBuilder {
        UserBuilder::new()
    }
}

Eventually with the builder now we can construct the same user structure:

let greyblake = User::builder()
    .email("example@example.com")
    .first_name("Serhii")
    .build();

While it is still slightly more verbose than the Ruby version of User.new, we got the traits we were aiming for:

Mandatory fields

Now imagine that User structure has mandatory fields id and email, this is much closer to the real life example:

struct User {
    id: String,
    email: String,
    first_name: Option<String>,
    last_name: Option<String>,
}

Builder can not have reasonable defaults for id and email anymore, so we have to find a way to pass them.

Again, in Ruby to enforce the presence of id and email we would just remove default nil values in the constructor:

class User
  def initialize(id:, email:, first_name: nil, last_name: nil)
  # ...
  end
end

In Rust to work around the problem, we could adjust the builder's constructor to receive values of mandatory fields:

struct UserBuilder {
    id: String,
    email: String,
    first_name: Option<String>,
    last_name: Option<String>,
}

impl UserBuilder {
    fn new(id: impl Into<String>, email: impl Into<String>) -> Self {
        Self {
            id: id.into(),
            email: email.into(),
            first_name: None,
            last_name: None,
        }
    }

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

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

    fn build(self) -> User {
        let Self { id, email, first_name, last_name } = self;
        User { id, email, first_name, last_name }
    }
}

impl User {
    fn builder(id: impl Into<String>, email: impl Into<String>) -> UserBuilder {
        UserBuilder::new(id, email)
    }
}

This allows us to construct a user being sure that id and email are always specified:

let greyblake = User::builder("13", "greyblake@example.com")
    .first_name("Serhii")
    .build();

Unfortunately, it brings us to the same problem which we had with new() constructor at the beginning of this article: field names are not spelled out explicitly and it is easy to screw up by passing arguments in the wrong order.

We'll see in the next article how this can be improved with help of Phantom Builder pattern 🐻.

Back to top