Builders, Defaults, Named Parameters, and Tab Complete
TODO:
write time vs read time abstractions
- functions
- find and replace
-
sometimes you need similar behavior with a few changes
- example: ask OS for access to a file
- can you read from it?
- can you write to it?
- will it be truncated?
- 4 ways to do it (in rust)
- just have 1 function (and specify each param each time)
- multiple functions
- options struct
- builder pattern
- language features
- ginger bill: not all nice things in a language need to be language features
- same idea: i thought java had way too much boiler plate to be productive, but then I saw https://youtu.be/miUbs3mqPJE?t=248 and it blew me away how fast this guy codes in Intellij. Java has a lot of boiler plate, but he blew through it like it was nothing
- example: ask OS for access to a file
-
virtual text (inlay hints) for “named vs unnamed arguments” argument
-
lsp based folding
-
snipets / tab complete
fn main() { let foo = Foo::new(); do_thi|}Tab
fn main() { let foo = Foo::new(); do_thing(|foo, 0)}Note here that the completion doesn’t need to just use the parameter name foo.
Your editor could figure out all the items with type Foo in scope. I believe
some of the JetBrains editors have this feature. For example, instead of just
foo here, in a drop down (similar to the completion menu) it could also
suggest Default::default() if Foo implemented Default, or Foo::new, and
created a nested completion if Foo::new had parameters.
Tab
fn main() { let foo = Foo::new(); do_thing(foo, |0)}Tab
fn main() { let foo = Foo::new(); do_thing(foo, 1)|}-
why do people like header files? They show you the api of the code (types, function signatures, class structures, interfaces, interface implementations), quickly, without detailing any of the implementation. you know what else does that, without duplicating the code (ya ya theres lsp rename which can rename in header files and implementation files). “Reader mode”
-
one argument against default parameters, is that you can’t see that there are extra parameters. solution: have some virtual text … at the end, or the full inline defaults
-
lets answer
- why do you want a builder?
- why do you don’t want a builder?
-
sgugaba: a case study
- Jon Gjengset is cracked at API design. When a library of his goes 1.0, pay close attention to what is a type, what isn’t a type, what is a builder, what isn’t a builder
- order dependent
-
tanstack-query (formerly react-query): a case study
-
drizzle-orm (really a query builder): a case study
I often end up with blocks of code that I need to reuse. I refactor them into reusable functions, which often needs some data which get passed as one or more arguments. What is the best way to pass data as arguments?
Let’s take the example of opening a file. When you ask the Linux kernel to give you access to a file, there are a few different options that change the behavior of how you can use file:
- read: permission to read from the file
- write: permission to write to the file
- append: writes append new writes to the end instead of overwriting
- truncate: sets the length to 0
- create: create the file if it doesn’t exist
- create_new: create the file if it doesn’t exist, and fail if it does exist
How would you write a function with that interface, allowing users to specify which options they want?
Here a very simple version of the API in C. Note, that files are represented as
ints in C. If opening the file fails, then the return value will be 0.
int open( const char *path, const bool read, const bool write, const bool append, const bool truncate, const bool create, const bool create_new);Using it would look like.
int main(void) { int fd = open( "hello.txt", true, true, true, false, true, false );}I’m not sure about you, but if I saw that code in the wild, I would have no idea
what it did… if I didn’t have any IDE tooling, but I use an IDE, and (most)
IDEs support inlay hints. Instead of reading a list of cryptic bool
parameters, they are labeled.
int main(void) { int fd = open( path: "hello.txt", read: true, write: true, append: true, truncate: false, create: true, create_new: false ); return 0;}That OK, but we can do better.
open from libc actually uses bit flags.
int open( const char *path, const int flags, const int mode);Using it looks like
int main(void) { int fd = open("hello.txt", O_RDWR | O_APPEND | O_CREAT, 0644); return 0;}One sneaky thing that bit flags allow you to do is to have defaults. If a flag
isn’t specified, it’s slot in the int will be 0.
Bit flags are great, but they only let you represent boolean options, so what are the ways that you can pass options to a function?
Forgive me for trying to fit a 3d table into a 2d screen.
| no order / order | multiple arguments | single argument |
|---|---|---|
| no defaults | specifying all parameters at the call site w/ named arguments / no named arguments | manually writing the options struct / writing the fields in order |
| defaults | named arguments / default arguments, function overloading | struct update syntax, bitflags, using the builder pattern / typestate builder pattern |
Multiple Arguments, No Defaults
// 1. Multiple Arguments, No Defaults, No Orderfn open( path: impl AsRef<Path>, read: bool, write: bool, append: bool, truncate: bool, create: bool, create_new: bool,) -> io::Result<File> { todo!() }open( "foo.txt", true, true, true, true, false, false,);
// 2. Multiple Arguments, No Defaults, Orderfn open( path: impl AsRef<Path>, read: bool, write: bool, append: bool, truncate: bool, create: bool, create_new: bool,) -> io::Result<File> { todo!() }open( "foo.txt" read=true, write=true, append=true, create=true, truncate=false, create_new=false,);
// 3. Multiple Arguments, Defaults, No Order (C++)fn open( path: impl AsRef<Path>, read: bool = false, write: bool = false, append: bool = false, truncate: bool = false, create: bool = false, create_new: bool = false,) -> io::Result<File> { todo!() }// Note: we can only leave off the last argument, because they arent namedopen("foo.txt", true, true, true, false, true);
// 4. Multiple Arguments, Defaults, Orderfn open( path: impl AsRef<Path>, read: bool = false, write: bool = false, append: bool = false, truncate: bool = false, create: bool = false, create_new: bool = false,) -> io::Result<File> { todo!() }open( "foo.txt", read=true, write=true, append=true, create=true);
#[derive(Default)]struct OpenOptions { read: bool, write: bool, append: bool, truncate: bool, create: bool, create_new: bool,}
// 5. Single Argument, No Defaults, No Orderfn open( path: impl AsRef<Path>, opts: OpenOptions,) -> io::Result<File> { todo!() }open( "foo.txt", OpenOptions { read: true, write: true, append: true, truncate: false, create: true, create_new: false, },);
// 6. Single Argument, No Defaults, Orderfn open( path: impl AsRef<Path>, opts: OpenOptions,) -> io::Result<File> { todo!() }open( "foo.txt", OpenOptions { read: true, write: true, append: true, truncate: false, create: true, create_new: false, },);let opts = OpenOptions::builder() .read(true), .write(true) .append(true), .create(true), .truncate(false), .create_new(false);open("foo.txt", opts);
// 7. Single Argument, Defaults, No Orderfn open( path: impl AsRef<Path>, opts: OpenOptions,) -> io::Result<File> { todo!() }open( "foo.txt", OpenOptions { read: true, write: true, append: true, create: true, },);let opts = OpenOptions::builder() .read(true), .write(true) .append(true), .create(true);open("foo.txt", opts);use OpenOptions as OO;open("foo.txt": OO::Read | OO::Write | OO::Append | OO::Create);
// 8. Single Argument, Defaults, Orderfn open( path: impl AsRef<Path>, opts: OpenOptions,) -> io::Result<File> { todo!() }open( "foo.txt", OpenOptions { read: true, write: true, append: true, create: true, },);let opts = OpenOptions::builder() .read(true), .write(true) .append(true), .create(true);open("foo.txt", opts);Another example
struct Ast;
struct TargetTriple(String);impl Default for TargetTriple { fn default() -> Self()}enum OptimizationLevel { None, Basic, Some, Full, Size,}enum DebugSymbols { Off, On,}
fn compile( ast: Ast, target: &TargetTriple,
)Single Argument, Multiple Data: SAMD
1. All the parameters, all the time
We just saw 1., and that has some downsides: parameters can be cryptic, swapping parameters of the same type is a common mistake, adding new parameters is a breaking API change (for languages without default parameters). However, most of those can be solved with tooling.
Aside: features != language features
<aside>
I disagree with a lot of the programming language (adjacent) design decisions Ginger Bill made, but I 100% agree with the statement that “not all features need to be language feature, they can be part of the compiler, external tools, IDEs, etc.” In a language like C where there are no (built in) default parameters or function overloading, you can still be super effective when programming because of the tooling.
In CLion and Neovim, when calling functions, you get a legend of which parameter you are currently filling out.
Another nice feature of C in CLion is the . syntax. C doesn’t have member
functions (methods), but you can still write . to call a “method”.
If you have a type struct VecU8 and a function
vecu8_push(struct VecU8 *vec, u8 element), you can use . to emulate methods.
int main(void) { struct VecU8 buf = {0}; buf.|}Since vecu8_push takes struct VecU8 * as the first parameter, CLion is smart
enough to figure out that its a “method” (in the OOP sense), and transform it
into the following:
Tab
int main(void) { struct VecU8 buf = {0}; vecu8_push(&buf, |)}They also have a super useful feature where you can press ; before
the ) and it puts it at the end:
int main(void) { struct VecU8 buf = {0}; vecu8_push(&buf, 67|)};
int main(void) { struct VecU8 buf = {0}; vecu8_push(&buf, 67);|}This currently isn’t possible in Neovim, but it can’t be too hard to implement.
</aside>
More languages!
Rust
fn open( path: impl AsRef<Path>, read: bool, write: bool, append: bool, truncate: bool, create: bool, create_new: bool,) -> io::Result<File> { todo!()}
fn main() { open("foo.txt", false, true, true, false, true, false);}2. Multiple functions
This is a common pattern where you have a few optional parameters, like rust’s
Vec. You can optionally pass in a capacity (with_capacity) or an
allocator (_in), and handle memory allocation failures (try_).
This leads to approximately constructor functions (in general, for optional parameters):
TODO: Links
| Initial Capacity? | Custom Allocator? | Result on OOM? | Function |
|---|---|---|---|
| No | No | No | Vec::new |
| No | No | Yes | † |
| No | Yes | No | Vec::new_in |
| No | Yes | Yes | † |
| Yes | No | No | Vec::with_capacity |
| Yes | No | Yes | Vec::try_with_capacity |
| Yes | Yes | No | Vec::with_capacity_in |
| Yes | Yes | Yes | Vec::try_with_capacity_in |
† constructing an empty Vec doesn’t allocate, so it can’t run into OOM errors,
and therefore can’t fail at runtime.
In some languages, like Java, Kotlin, C++, C#, and Swift, you can keep the function name the same, but that really messes with goto definition and code searching. I don’t mind the function explosion, but there are other ways to solve it.
3. Options struct
#[derive(Default)]#[non_exhaustive] // key for API compatibilitystruct OpenOptions { read: bool, write: bool, append: bool, truncate: bool, create: bool, create_new: bool}
fn open( path: impl AsRef<Path>, options: &OpenOptions,) -> io::Result<File> { todo!() }
fn main() { open("foo.txt", &OpenOptions { read: true, write: true, create: true, ..Default::default() });}This is using rust’s
struct update syntax
along with a Default implementation.
This is common bevy, where structs can have upwards of 40 parameters (go team fat structs).
4. Builder pattern
TODO:
Builders are loved in the rust community, and personally, I’m a huge fan of
method chaining. I think it makes for “readable” code (more on that later).
(Yes, I’m also a fan of .await.)
OpenOptions is actually implemented as a builder in the standard library
(std::fs::OpenOptions)
// Simplified, but mostly the samepub struct OpenOptions { read: bool, write: bool, append: bool, truncate: bool, create: bool, create_new: bool}
impl OpenOptions { fn new() -> Self { return OpenOptions { read: false, write: false, append: false, truncate: false, create: false, create_new: false, }; }
pub fn read(&mut self, read: bool) -> &mut Self { self.read = read; return self; } pub fn write(&mut self, write: bool) -> &mut Self { self.write = write; return self; } pub fn append(&mut self, append: bool) -> &mut Self { self.append = append; return self; } pub fn truncate(&mut self, truncate: bool) -> &mut Self { self.truncate = truncate; return self; } pub fn create(&mut self, create: bool) -> &mut Self { self.create = create; return self; } pub fn create_new(&mut self, create_new: bool) -> &mut Self { self.create_new = create_new; return self; } pub fn open(&self, path: Path) -> io::Result<File> { todo!() }}fn main() { OpenOptions::new() .read(true) .write(true) .create(true) .open("foo.txt");}That’s a lot of code. (Note the current std implementation is ~240 lines long,
but a lot of that is because of documentation). But in rust, you can also
automatically generate the boilerplate-y code via a
derive macro.
#[derive(Builder, Default)]pub struct OpenOptions { read: bool, write: bool, append: bool, truncate: bool, create: bool, create_new: bool}impl OpenOptions { pub fn open(&self, path: Path) -> io::Result<File> { todo!() }}However, using a proc macro isn’t free. It increases compile times and makes debugging harder because the generated source isn’t right in front of you.
TODO: typestate builders, validation, etc
5. Default parameters
In languages that support them, you could also use default parameters. I don’t mind default parameters…
…is what I would have said if I didn’t stumble upon this thread.
Comparison
These are small(ish) differences. Whether you decide to use a builder, or have
to specify NULL/None four times doesn’t really make a difference when
reading or writing code. It’s 2025, we have LSPs and AI autocomplete.
Sure, maybe when reading code on GitHub or in Notepad(++), having named parameters is more clear than positional ones, but should we really be optimizing for those environemnts, and should the solution be a language feature?
But, enough of those “small differences” compel people to design new languages.
Readability
More code != bad. Less code != more readable.
I don’t think that
Cat socrates = new Cat( "Socrates", favoriteFood="Tuna", hoursAwake=5);is more or less readable than
Cat socrates = new Cat .Builder("Socrates") .FavoriteFood("Tuna") .HoursAwake(5) .Build();Now, if your language doesn’t support named parameters, like C, Rust, Go, Java, Kotlin, C++ (has defaults, but not named), than builders are a great replacement.
TODO: inlay hints
Below are some examples of what happens in languages without default / named parameters.
int sock = socket(AF_INET, SOCK_STREAM, 0);let cat = Cat::new("Socrates", None, None);Validation
Let’s take a look at an example where that might not be the case. Here is a function from the tensorflow api. ML python libraries are notorious for having many default parameters.
add_weight( shape=None, initializer=None, dtype=None, trainable=True, autocast=True, regularizer=None, constraint=None, aggregation='mean', name=None)This might not be the case here, but when you have these “add_” methods that
take a bunch of parameters, you normally have to validate certain cases. Like
maybe shape has to be a certain length of regularized is set.
Creating a Weight builder and changing the function to
add_weight(weight: Weight) allows the validation code to be dudplicated.
- should it be the calling code or the function to validate?
Why is having so many parameters bad?
API Design
One of the most interesting uses of the builder pattern is from Jon Gjengset’s “where are you in space” library. You can construct a location on a sphere with , or with Asmuth.
With spherical coordinates, the order which you specify them matters, because …
To enforce that they go in the correct order, he used a typestate builder, to enforce the order they were set in.
? Why not just function with 3 things?
-
struct parameters are named (only).
-
https://golang.cafe/blog/golang-functional-options-pattern.html
Guidelines
If you don’t need order
- if you need validation
- use a builder or an options struct with a post validation function that returns a new type
- if you don’t need validation
-
If your function has less than 3 parameters
-
If you need order
- use a builder with multiple types where you can only call 1 function after the other, e.g. x.builder().a().b(), and not .b().a() because .b() doesn’t exist on the return type of .builder(), and .a() doesn’t exist on the return type of .b()