Skip to content

How to do named function arguments in Rust

2024-07-28

Many modern languages have a built-in feature called "named function arguments". This is how it looks in Python, for example:

py
def greet(name: str, age: int) -> str:
    return f"Hello {name} with age {age}!"

greeting = greet(
    # Notice the `key = value` syntax at the call site
    name="Bon",                                        
    age=24,                                            
)

assert greeting == "Hello Bon with age 24!"

This feature allows you to associate the arguments with their names when you call a function. It improves readability, API stability, and maintainability of your code.

It's just such a nice language feature, which is not available in Rust, unfortunately. However, there is a way to work around it, that I'd like to share with you.

Naive Rust solution

Usually, Rust developers deal with this problem by moving their function parameters into a struct. However, this is inconvenient, because it's a lot of boilerplate, especially if your function arguments contain references:

rust
// Meh, we need to annotate all lifetime parameters here
struct GreetParams<'a> {
    name: &'a str,
    age: u32
}

fn greet(params: GreetParams<'_>) -> String {
    // We also need to prefix each parameter with `params` or manually
    // destructure the `params` at the start of the function.
    format!("Hello {} with age {}!", params.name, params.age)
}

// Ugh... consumers need to reference the params struct name here,
// which usually requires an additional `use your_crate::GreetParams`
greet(GreetParams {
    name: "Bon",
    age: 24
});

This situation becomes worse if you have more references in your parameters or if your parameters need to be generic over some type. In this case, you need to duplicate the generic parameters both in your function and in the parameters' struct declaration. Also, the convenient function's impl Trait syntax is unavailable with this approach.

The other caveat is that the struct literal syntax doesn't entirely solve the problem of omitting optional parameters. There is a clunky way to have optional parameters by requiring the caller to spread the Default parameters struct instance in the struct literal.

rust
struct GreetParams<'a> {
    name: &'a str,
    age: u32,

    // Optional
    job: Option<String> 
}

GreetParams {
    name: "Bon",
    age: 24,

    // But.. this only works if your whole `GreetParams` struct
    // can implement the `Default` trait, which it can't in this
    // case because `name` and `age` are required
    ..GreetParams::default() 
}

This situation can be slightly improved if you derive a builder for this struct with typed-builder, for example. However, typed-builder doesn't remove all the boilerplate. I just couldn't stand, but solve this problem the other way.

Better Rust solution

I've been working on a crate called bon that solves this problem. It allows you to have almost the same syntax for named function arguments in Rust as you'd have in Python. bon generates a builder directly from your function:

rust
#[bon::builder] 
fn greet(name: &str, age: u32) -> String {
    format!("Hello {name} with age {age}!")
}

let greeting = greet()
    .name("Bon") 
    .age(24)     
    .call();

assert_eq!(greeting, "Hello Bon with age 24!");
py

def greet(name: str, age: int) -> str:
    return f"Hello {name} with age {age}!"


greeting = greet(
    name="Bon", 
    age=24,     
)

assert greeting == "Hello Bon with age 24!"

We only placed #[bon::builder] on top of a regular Rust function. We didn't need to define its parameters in a struct or write any other boilerplate. In fact, this Rust code is almost as succinct as the Python version. Click on Python at the top of the code snippet above to compare both versions.

I hope you didn't break that button 🐱

The builder generated by bon allows the caller to omit any parameters that have Option type. That part is covered.

It supports almost any function syntax. It works fine with functions inside of impl blocks, async functions, generic parameters, lifetimes (including implicit and anonymous lifetimes), impl Trait syntax, etc.

Furthermore, it also never panics. All errors such as missing required arguments are compile-time errors ✔️.

bon allows you to easily switch your regular function into a function "with named parameters" that returns a builder. Just adding #[builder] to your function is enough even in advanced use cases.

One builder crate to rule them all

bon was designed to cover all your needs for a builder. It works not only with functions and methods, but it also works with structs as well via a #[derive(Builder)]:

rust
use bon::Builder;

#[derive(Builder)]
struct User {
    id: u32,

    // This attribute makes the setter accept `impl Into<String>`
    // which lets us pass an `&str` directly and it'll be automatically
    // converted into `String`.
    #[builder(into)]
    name: String,
}

User::builder()
    .id(1)
    .name("Bon")
    .build();

So, you can use just one builder crate solution consistently for everything. Builders for functions and structs both share the same API design, which allows you, for example, to switch between a #[derive(Builder)] on a struct and a #[builder] attribute on a method that creates a struct. This won't be an API-breaking change for your consumers (details).

Summary

If you'd like to know more about bon and use it in your code, then visit the guide page with the full overview.

bon is fully open-source, and available on crates.io. Its code is public on Github. Consider giving it a star ⭐ if you liked the idea and the implementation.

Acknowledgements

This project was heavily inspired by such awesome crates as buildstructor, typed-builder and derive_builder. bon crate was designed with many lessons learned from them.

TIP

You can leave comments for this post on Reddit.

Veetaha

Veetaha

Lead developer @ elastio

Creator of bon