Rust Named/Optional Parameters

Rust doesn’t support named or optional parameters. This blog post will explore alternate ways to achieve similar functionality.

Using raw structs #

Given

struct Request {
    url: String,
    method: String,
    body: Option<String>,
    headers: Option<HashMap<String, String>>,
}

We can quite easily create a Request using the Request struct directly:

fn main() {
    let request = Request {
        url: "https://example.com".to_string(),
        method: "GET".to_string(),
        body: None,
        headers: None,
    };
}

However, this is not very ergonomic.

Using a Builder pattern #

struct Request {
    url: String,
    method: String,
    body: Option<String>,
    headers: Option<HashMap<String, String>>,
}

impl Request {
    fn new(url: impl Into<String>) -> Self {
        Self {
            url: url.into(),
            method: "GET".to_string(),
            body: None,
            headers: None,
        }
    }

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

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

    fn header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
        self.headers.get_or_insert_with(HashMap::new).insert(key.into(), value.into());
        self
    }
}

This allows us to create a Request in a more ergonomic way. However, this is still not great because

  1. This is a lot of boilerplate
  2. We still don’t have named parameters for required fields

Using a partial struct #

struct PartialRequest {
    url: String,
}

struct Request {
    url: String,
    method: String,
    body: Option<String>,
    headers: Option<HashMap<String, String>>,
}

impl PartialRequest {
    fn combine_defaults(self) -> Request {
        Request {
            url: self.url,
            method: "GET".to_string(),
            body: None,
            headers: None,
        }
    }
}

impl Request {
    fn method(self, method: impl Into<String>) -> Request {
        Request {
            url: self.url,
            method: method.into(),
            body: None,
            headers: None,
        }
    }

    // ...
}

Yet doing this, we

Libraries #

derive_builder #

rust-typed-builder #

My custom #[derive(Build)] #

I combine the more traditional Java approach of having a separate method for required parameters. It is ergonomic, but for me personally not too hacky like rust-typed-builder.

Unfortunately, required fields are not named, but I think this is a good tradeoff for not having decently-hacky code.

#[derive(Debug, Build, Serialize)]
pub struct ChatRequest {
    #[required]
    pub model: ChatModel,
    pub messages: Vec<Msg>,

    #[serde(skip_serializing_if = "real_is_one")]
    #[default = 1.0]
    pub temperature: f64,

    #[serde(skip_serializing_if = "real_is_one")]
    #[default = 1.0]
    pub top_p: f64,

    #[serde(skip_serializing_if = "int_is_one")]
    #[default = 1]
    pub n: u32,

    #[serde(skip_serializing_if = "empty", rename = "stop")]
    pub stop_at: Vec<String>,
}

I find it to be really useful to create your own #[derive(Build)] macro, as it allows you to customize the behavior of the Builder pattern to your liking.

For instance:

The macro expands to the following:

impl ChatRequest {
    pub fn new(model: impl Into<ChatModel>) -> Self {
        Self {
            model: model.into(),
            messages: Default::default(),
            temperature: 1.0,
            top_p: 1.0,
            n: 1,
            stop_at: Default::default()
        }
    }

    pub fn message(mut self, message: impl Into<Msg>) -> Self {
        self.messages.push(message.into());
        self
    }

    pub fn temperature(mut self, temperature: impl Into<f64>) -> Self {
        self.temperature = temperature.into();
        self
    }

    pub fn top_p(mut self, top_p: impl Into<f64>) -> Self {
        self.top_p = top_p.into();
        self
    }

    pub fn n(mut self, n: u32) -> Self {
        self.n = n.into();
        self
    }

    pub fn stop_at(mut self, stop_at: impl Into<String>) -> Self {
        self.stop_at.push(stop_at.into());
        self
    }
}

Outside of Rust #

My favorite language that handles this well is Kotlin. Ideally, Rust will have something similar to how they handle this as part of the language.

In Kotlin, one can mix named and positional parameters, and the compiler will be smart about positioning (unlike Python where all positional parameters must come before named parameters).

fun foo(a: Int, b: Int, c: Int = 0, d: Int = 0) = a + b + c + d

foo(1, 2) // 3
foo(1, 2, 3) // 6
foo(1, 2, d = 3) // 6
foo(1, 2, d = 3, c = 4) // 10
foo(1, 2, d = 3, 4) // 10