Optional or default parameters are a very interesting feature of some languages that Rust specifically doesn’t cover (and looks like it won’t anytime soon). Say your library has lots of endpoints like so:

fn endpoint<T1, T2, T3, ...>(mandatory: T1, opt1: Option<T2>, opt2: Option<T3>, ...);

In this case, when you call endpoint, you have to use endpoint(mandatory, None, None, ...), or endpoint(mandatory, Some(val1), Some(val2), ...), instead of the more intuitive endpoint(mandatory) or endpoint(mandatory, val1, val2). Other languages like Python have named arguments, which make optional parameters natural and easier to read: endpoint(mandatory, opt1=val1, opt2=val2), while also allowing them to be written in any order.

Even without official support, there are lots of different ways to approach them in Rust, which is what this blog post tries to analyze. My goal is not to show which one is the “best” option, but to exhaustively showcase the different ways they can be approached, and the ups and downs of each of them.

Introducing an example

Let’s start with a typical web API wrapper library. These often require a client struct to hold authentication fields and such:

#[derive(Default, Debug, Clone)]
struct APIClient;

This client will have various endpoints which can be used to send requests to the server. We will use a function with the actual implementation and some type aliases so that the following snippets are easier to read:

use std::error::Error;

/// Some value that the endpoint will return
#[derive(Debug, Default)]
struct Value;
type ReturnedValue = Result<Value, Box<dyn Error>>;

/// The actual code for the endpoint inside the client
impl APIClient {
    fn actual_endpoint(name: &str, opt1: Option<u32>, opt2: Option<i32>) -> ReturnedValue {
        println!("params: {} {:?} {:?}", name, opt1, opt2);

        Ok(Default::default())
    }
}

A) Using Option<T>

The simplest way to do this would be to just use the actual_endpoint function signature. Sometimes the best solution is the simplest, and your project just might not need more complex approaches. Do consider if anything more elaborate is actually necessary.

For the sake of this example, it would look like this:

impl APIClient {
    pub fn approach_a(&self, name: &str, opt1: Option<u32>, opt2: Option<i32>) -> ReturnedValue {
        self.actual_endpoint(name, opt1, opt2)
    }
}
let api = APIClient {};
let param2: u32 = 324;

api.approach_a("option", Some(param2), Some(1234))?;
api.approach_a("option", None, None)?;

Upsides

  • Simplest to understand and implement.

Downsides

  • Multiple optional parameters require lots of None and Some.
  • Parameter names unknown when reading the code, which is specially annoying with None values, since these don’t have context to know what they are for.

B) With Into<Option<T>>

A variation of the previous approach consists on using Into<Option<T>> as the generic value for the optional parameters (impl Into<Option<T>> can be used as well). This way, Some isn’t needed when the optional parameters are specified:

impl APIClient {
    pub fn approach_b<T1, T2>(&self, name: &str, opt1: T1, opt2: T2) -> ReturnedValue
    where
        T1: Into<Option<u32>>,
        T2: Into<Option<i32>>,
    {
        self.actual_endpoint(name, opt1.into(), opt2.into())
    }
}
api.approach_b("into_option", param2, 1234)?;
// This still works
api.approach_b("into_option", Some(param2), Some(123))?;
api.approach_b("into_option", None, None)?;

Upsides

  • Some isn’t required, which makes it slightly easier to read.

Downsides

  • Multiple optional parameters still require lots of Nones.
  • No parameter names either.
  • More complex function signatures, and might not be too “idiomatic”.
  • Requires generics, 2^N copies of this function may be generated, where N is the number of optional parameters.

C) With a custom struct

Another option is to create a struct that holds the parameters and use that instead. The complexity is still relatively simple, and it can work out well if the API has functions with repetitive function signatures. This will serve as a base for the following approaches as well:

let call1 = params::ApproachC {
    name: "builder".to_string(),
    opt1: Some(param2),
    opt2: Some(123),
};
api.approach_c(&call1)?;

We can even use ..Default::default() to initialize the rest of the parameters with their default values:

let call2 = params::ApproachC {
    name: "builder".to_string(),
    ..Default::default()
};
api.approach_c(&call2)?;

The implementation looks like this:

mod params {
    /// We derive `Default` to be able to initialize with
    /// `..Default::default()`.
    #[derive(Default)]
    pub struct ApproachC {
        pub name: String,
        pub opt1: Option<u32>,
        pub opt2: Option<i32>,
    }
}

impl APIClient {
    pub fn approach_c(&self, data: &params::ApproachC) -> ReturnedValue {
        self.actual_endpoint(&data.name, data.opt1, data.opt2)
    }
}

Upsides

  • Some APIs might feel more natural this way, if the combination of these parameters as a group makes sense.
  • The struct can be reused in different calls.

Downsides

  • Multiple optional parameters require lots of None and Some.
  • Way more verbose.
  • Can be difficult to scale, since it needs a struct and a function per endpoint.

D) With the builder pattern

The previous approach can be improved by using the builder pattern for the parameters, so that building the parameters is simpler and more pretty to look at:

let call1 = params::ApproachDBuilder::default()
    .name("builder")
    .opt1(param2)
    .opt2(2134)
    .build()?;
api.approach_d(&call1)?;

let call2 = params::ApproachDBuilder::default()
    .name("builder")
    .build()?;
api.approach_d(&call2)?;

In this case, we use the derive_builder crate to make the implementation less repetitive:

mod params {
    #[derive(Default, Builder)]
    pub struct ApproachD {
        #[builder(setter(into))]
        pub name: String,
        #[builder(setter(strip_option), default)]
        pub opt1: Option<u32>,
        #[builder(setter(strip_option), default)]
        pub opt2: Option<i32>,
    }
}

impl APIClient {
    pub fn approach_d(&self, data: &params::ApproachD) -> ReturnedValue {
        self.actual_endpoint(&data.name, data.opt1, data.opt2)
    }
}

Upsides

  • It’s the most popular pattern in Rust for optional parameters.
  • The struct can still be reused in different calls.
  • No None or Some at sight.
  • Optional parameters are now associated to their name, which makes it easier to read.

Downsides

  • Somewhat verbose.
  • In our example it can be difficult to scale, since it needs a struct and a function per endpoint.
  • More overhead, both at runtime and compile-time (specially if macros are used).
  • Constructing the values may fail. Thus, the documentation has to specify very clearly which parameters are mandatory and which aren’t.

E) Endpoint-oriented interface

Here’s a different take: what if the API was endpoint-oriented instead of client-oriented? Starting from the previous approach, we could make the call inside the endpoint struct itself instead of in the API’s client by adding it to ApproachE, or by overriding the build method from derive_builder for a slightly less verbose version:

ApproachEBuilder::default()
    .name("endpoint-oriented")
    .opt1(param2)
    .opt2(1111)
    .call(&api)?;
ApproachEBuilder::default()
    .name("endpoint-oriented")
    .call(&api)?;

And its implementation:

#[derive(Default, Builder)]
#[builder(build_fn(private))]
struct ApproachE {
    #[builder(setter(into))]
    pub name: String,
    #[builder(setter(strip_option), default)]
    pub opt1: Option<u32>,
    #[builder(setter(strip_option), default)]
    pub opt2: Option<i32>,
}

impl ApproachEBuilder {
    pub fn call(&self, client: &APIClient) -> ReturnedValue {
        let data = self.build()?; // This might fail!
        // `actual_endpoint` would have to be at least pub(in crate) in this
        // case.
        client.actual_endpoint(&data.name, data.opt1, data.opt2)
    }
}

Note: this is assuming the client contains necessary information to make the requests, like a reqwest::Client or authentication details. But for example, ureq can perform calls without a client instance, just by calling ureq::get and similars. In that case, the API could just not have a client at all, and the call method wouldn’t require a reference to the client.

Upsides

  • Fits perfectly for some specific APIs.
  • No None or Some needed.
  • Just as readable as the previous case.
  • The struct may be reused.
  • Simple to use and implement, since it doesn’t need declaring both a function in the client and a parameters struct, only the latter.

Downsides

  • Still relatively verbose, might not be compatible with some APIs.
  • Slight builder pattern overhead, and can also fail to construct the value.

F) Hybrid derive pattern Back to the client-oriented API. We can remove some

disadvantages of the builder pattern by using a different approach and possibly a custom implementation instead of just using derive_builder.

The client will now have a method that calls ApproachBuilder::default() and whatever is necessary to start building the endpoint. Mandatory parameters are added to that function’s signature, so that the build can never fail. This also avoids passing the client in call(&api), since we can do that inside the method. This is how it would look like:

api.approach_f("hybrid-derive-pattern")
    .opt1(param2)
    .opt2(2222)
    .call()?;

And an implementation by wrapping derive_builder:

impl APIClient {
    pub fn approach_f(&self, name: &str) -> ApproachFBuilder {
        ApproachFBuilder::default().client(self).name(name)
    }
}

/// Not meant to be used directly, only within `APIClient`.
#[derive(Default, Builder)]
#[builder(build_fn(private), pattern = "owned")]
pub struct ApproachF<'a> {
    #[builder(setter(strip_option), default)]
    client: Option<&'a APIClient>,
    #[builder(setter(into))]
    pub name: String,
    #[builder(setter(strip_option), default)]
    pub opt1: Option<u32>,
    #[builder(setter(strip_option), default)]
    pub opt2: Option<i32>,
}

impl ApproachFBuilder<'_> {
    pub fn call(self) -> ReturnedValue {
        let data = self.build().unwrap(); // This should never fail
        data.client
            .unwrap()
            .actual_endpoint(&data.name, data.opt1, data.opt2)
    }
}

derive_builder doesn’t know that build() can never technically fail because the mandatory parameters are provided inside the wrapper, so it’s kind of a waste to use this macro. It also forces us to create the wrapper because the builder always has to be initialized with Builder::default(). With a custom implementation, we could have this new method integrated in the builder itself instead, like Builder::new(A, B, C).

Thus, it might be better to use other crates like typed-builder, or just a custom implementation.

Upsides

  • Although mandatory parameters don’t have a parameter name now, it’s still quite readable. It’s also much less verbose.
  • No None or Some needed.
  • Can’t fail to initialize the endpoint value, so it’s “safer”.
  • A custom implementation would avoid runtime overhead.

Downsides

  • Currently a bit hacky to implement, which makes it much more complex, specially because there’s no existing macro that can simplify this specific variation of the builder pattern (that I know of). It requires both a function and a struct as well, so the implementation can be quite lengthy.
  • Still some compilation-time overhead.

G,H) Grouping up endpoints

Another possible approach based on the previous approach of the builder pattern consists on grouping up all or some of the endpoints under a single struct. They will share the optional parameters, which is useful to avoid declaring a struct with optional parameters for each endpoint we have, and makes a lot of sense for some APIs.

The call method could be removed in place of approach_f, and the optional parameters would go first. This would require a different order for the optional parameters. Here’s an example if all the endpoints shared the same optional parameters:

api.opt1(param2)
    .opt2(2222)
    .approach_f("group-builder-pattern")?;

And if we used different groups it would look like this:

api.group()
    .opt1(param2)
    .opt2(2222)
    .approach_g("group-builder-pattern")?;
api.group().opt2(2222).approach_h("builder-from-scratch")?;

Here’s an implementation with derive_builder:

impl APIClient
    pub fn group(&self) -> GroupBuilder {
        GroupBuilder::default().client(self)
    }
}

#[derive(Default, Builder)]
#[builder(build_fn(private), pattern = "owned")]
pub struct Group<'a> {
    #[builder(setter(strip_option), default)]
    client: Option<&'a APIClient>,
    #[builder(setter(strip_option), default)]
    pub opt1: Option<u32>,
    #[builder(setter(strip_option), default)]
    pub opt2: Option<i32>,
}

impl GroupBuilder<'_> {
    pub fn approach_g(self, name: &str) -> ReturnedValue {
        let data = self.build().unwrap();
        data.client
            .unwrap()
            .actual_endpoint(name, data.opt1, data.opt2)
    }

    pub fn approach_h(self, name: &str) -> ReturnedValue {
        let data = self.build().unwrap();

        // This endpoint doesn't need `opt1`. It can either be ignored, or
        // be an error. I hate silent errors, though.
        if data.opt1.is_some() {
            panic!("opt1 isn't needed for enpoint_h")
        }

        data.client.unwrap().actual_endpoint(name, None, data.opt2)
    }
}

As the approach_h endpoint indicates, the shared optional parameters don’t actually have to be strictly the same. As long as they are related, they can share the same group, and some extra verifications can be added in the final call to make sure the user is using it properly.

Upsides

  • Basically the same as the hybrid builder pattern, but with an easier implementation, and it might fit perfectly for some APIs that have clearly established groups of endpoints.

Downsides

  • It’s a bit odd, specially because the order is inverse to what you’d expect. And as it’s based on the hybrid builder pattern, it may still be hacky to implement and require compilation-time overhead.

I) Macros

Rust macros support variadic arguments, which make it possible to create a macro with named parameters, like foo!(1, c = 30, b = -2.0). Ideally, we want a macro that generates the macros for us, which does sound crazy. I wanted to at least try how existing crates approached this, and only found named and duang, which haven’t been updated in years, and probably for good. I tried duang with Rust 1.47 but got some unexpected errors, so we can assume there are no crates that support this yet. It definitely sounds like a fun challenge, if it’s still possible to implement.

Conclusion

Whew! That took more than I expected. Some of these endpoints might be unnecessarily complicated or straight up weird. But I hope this was as a good showcase of the different ways optional parameters can be approached in Rust, and that reading this served as a learning experience. I look forward to seeing new crates in the future that simplify these approaches.

The code for the different approaches can be found here. Bear in mind that there are a lot of different ways to implement the approaches, as I explained in this post. You can discuss it at the reddit thread.

Disclaimer: this post was originally in https://vidify.org/blog/rust-parameters/. I’ve moved it to my personal blog.