Introducing Assemblist: Fluent Method-Chain Builders for Rust

Hi Rustaceans,

I wanted to share assemblist, a library I created to simplify immutable builder patterns in Rust. I know the ecosystem already offers many builder-related crates, but I wanted to explore a different approach — one that makes defining method chains feel more natural and intuitive.

Unlike traditional builders tied to struct annotations, macro assemblist! lets you declare method chains as you use them, making APIs clearer and more flexible.

Example Usage

Here's a simple example demonstrating the idea:

assemblist!{
    fn define_movie<'a>(name: &'a str)
        .released_in(release_year: usize)
        .directed_by(director_name: &'a str) -> Movie
    {
        Movie {
            name: name.to_string(),
            release_year,
            director_name: director_name.to_string(),
        }
    }
}

Once declared, method chains can be called fluently:

let movie = define_movie("The Lobster")
    .released_in(2015)
    .directed_by("Yorgos Lanthimos");

Supporting Alternative Paths

Assemblist also introduces alternative blocks, allowing users to define multiple possible continuations for a chain using a .{ … } scope. This mechanism is recursive, enabling expressive branching.

assemblist!{
    fn new_http_request_to(url: Uri)
        .from<'a>(user_agent: &'a str)
        .with_authorization(authorization: HttpAuthorization).{

        fn as_get() -> GetHttpRequest {
            GetHttpRequest {
                url,
                user_agent: user_agent.to_string(),
                authorization,
            }
        }

        fn as_post().{
            fn with_text(body: String) -> PostHttpRequest {
                PostHttpRequest {
                    url,
                    user_agent: user_agent.to_string(),
                    authorization,
                    body: HttpBody::Text(body),
                }
            }

            fn with_json(json: JsonValue) -> PostHttpRequest {
                PostHttpRequest {
                    url,
                    user_agent: user_agent.to_string(),
                    authorization,
                    body: HttpBody::Json(json),
                }
            }
        }
    }
}

Looking for Feedback

Assemblist is available on crate.io, and I’d love to hear your thoughts.

7 Likes

this is very neat!

builder patterns are often said to be alternative for named arguments and optional arguments.

it is not immediately clear by reading the documentation, but I wonder does this library support reordered arguments and optional (or default) arguments?

reordering needs no more explanation. here's how I imagined what default arguments might look like (probably desugared to Option:unwrap_or_else()):

    fn define_movie<'a>(name: &'a str)
        .released_in(release_year: usize = Date::new().year())
        .directed_by(director_name: &'a str) -> Movie
    {
        //...
    }

another question, does it support generics? and can I just elide the lifetimes?

    fn define_movie(name: &impl ToString)
        .released_in(release_year: usize)
        .directed_by<S: ToString>(director_name: &S) -> Movie {
        //...
    }

just FYI, this is the library I have been using to generate builders for structs:

I also know about this newer library, but I have not used it personally:

Your last crates.io link is incorrect.

1 Like

this is very neat!

Hi @nerditation. Thanks for your comment ! :grinning_face_with_smiling_eyes:

it is not immediately clear by reading the documentation, but I wonder does this library support reordered arguments

I'm not sure what exactly you call reordered arguments. The provider of a given builder is free to chain methods in the order they want, independently of the potential fields of a struct to build. However the consumer of this builder must call methods in the order offered by the provider and cannot reorder them (unless, of course, if the provider does offer alternative paths containing the same method names in different order).

here's how I imagined what default arguments might look like (probably desugared to Option:unwrap_or_else()):

   fn define_movie<'a>(name: &'a str)
       .released_in(release_year: usize = Date::new().year())
       .directed_by(director_name: &'a str) -> Movie
   {
       //...
   }

Default arguments are not supported this way but can possibly be emulated with alternative paths in simple cases. But I agree that if you have many of them, current approach may not be suitable. Your proposal is actually quite interesting :+1: I had previously envisaged other possible designs, but with not clear opinion on the direction to go:

  • Having "regex-like" modifiers like ? or * :
    fn define_movie<'a>(name: &'a str)
         .released_in(release_year: usize)
         .of_genre(genre: MovieGenre)? // can be used or skipped
         .with_actor(actor_name: &'a str)* // can be used multiple times
         .directed_by(director_name: &'a str) -> Movie
    {
         let optional_genre: Option<MovieGenre> = genre;
         let actor_names: Vec<&'a str> = actor_name;
         //...
    }
    
  • Having non-terminating alternative blocks generating enums:
    fn define_movie<'a>(name: &'a str)
        .release_alt as {
            fn released_in(release_year: usize);
            fn released_this_year();
        }
        .genre_alt as {
            fn of_genre(genre: MovieGenre);
            fn of_many_genres(genres: &'a [MovieGenre]);
            fn of_unspecified_genre();
        }
        .directed_by(director_name: &'a str) -> Movie
    {
         match release_alt {
           release_alt::released_in(release_year) => { /* ... */ }
           release_alt::released_this_year => { /* ... */ }
         }
         match genre_alt {
           genre_alt::of_genre(genre) => { /* ... */ }
           genre_alt::of_many_genres(genres) => { /* ... */ }
           genre_alt::of_unspecified_genre => { /* ... */ }
         }
         //...
    }
    

The last one is by far the most expressive but also the most complicated to implement. Actually your proposal might be a low hanging fruit. Any opinion?

another question, does it support generics?

Yes. You can declare generic types (with possible trait bounds), lifetimes and generic const value in each section of a method chain. You can also use a where clause.

and can I just elide the lifetimes?

Unfortunately not right now. I don't know if it is easily doable as it implies macro assemblist! should be able to reason about how many distinct lifetimes should be implicitely injected and where.