TL;DR: go-tool-base configures things with functional options, and forgetting a required one is a runtime failure at best. Most builder patterns have the same hole: .build() is always callable, whether or not you supplied the required fields. rust-tool-base uses typestate builders via the bon crate, where .build() does not exist as a method until every required field has been set. Forget one and the code does not compile, and the error names the call you missed.
When is a required field actually required
Every framework has constructors with a mix of required and optional inputs. An Application in rust-tool-base needs tool metadata and a version. It optionally takes a custom config type, extra commands, feature toggles. The metadata needs a name and a summary; a description and a help channel are optional.
The interesting question is when “required” gets enforced. There are really only two moments available: when the program runs, or when it compiles. Most APIs pick the first without ever framing it as a choice.
How go-tool-base does it
go-tool-base uses functional options, the standard Go pattern:
tool := props.New(
props.WithName("mytool"),
props.WithVersion(version),
)
New takes a variadic list of options and applies them. It’s flexible and it reads well. But look at what the type says. New accepts zero or more options. The signature is satisfied by passing nothing at all. If WithName is required, nothing in the type system knows that. Forget it and the code compiles cleanly, and you find out when the program runs, or worse, when it doesn’t visibly fail but carries an empty name into everything downstream.
A plain builder is no better here. builder.name("mytool").build() and builder.build() are both valid calls as far as the compiler is concerned. The builder hopes you set the name. It can check at the end and return an error, but that check still happens at runtime.
In every one of these the required-ness of a field is a fact that lives in documentation and in the author’s head, not in the code.
Typestate: putting “required” in the type
rust-tool-base builds these with bon, and the pattern it generates is a typestate builder. The idea is that the builder’s type changes as you call it, and that type tracks which required fields you have set so far.
let metadata = ToolMetadata::builder()
.name("mytool")
.summary("my CLI tool")
.build();
ToolMetadata::builder() returns a builder in a state that records “name not set, summary not set”. Calling .name(...) consumes that builder and returns a different type, one whose state records “name set”. Calling .summary(...) does the same for the summary.
The part that matters is .build(). It is not a method on the builder in general. It only exists on the builder type that represents “every required field has been set”. So this:
let metadata = ToolMetadata::builder()
.summary("my CLI tool")
.build();
does not compile. Not because a runtime check fired, but because in the state “name not set” there is no .build() method to call. The compiler stops you, and the error points straight at the missing .name(...).
Optional fields stay optional. You can call .description(...) or skip it, and .build() is reachable either way, because the description was never part of the state that gates it. The required and the optional are genuinely different in the type, which is exactly the distinction the functional-options version could only keep in a comment.
Application::builder() works the same way. It will not produce an Application until it has metadata and a version, and “will not” there means the method is absent, not that a check returns Err.
Why the moment matters
Moving the check from run time to compile time changes who finds the mistake and when.
A runtime check finds it when that code path executes, which might be in a test, might be in CI, might be on a user’s machine. A compile-time check finds it the moment you write it, in the editor, before anything has run. The same mistake, caught at the cheapest possible point instead of one of the more expensive ones.
It also changes what the API documents about itself. A functional-options constructor cannot tell you, from its signature alone, which options you must pass. A typestate builder can, because the set of methods available to you at each step is the documentation. You literally cannot reach .build() without having been walked past every required field.
This is one of those places where Rust’s type system earns its reputation. The builder isn’t more careful than the Go version. It’s that “this field is required” stopped being a convention and became something the compiler enforces.
Summary
Required fields have to be enforced somewhere. Functional options and ordinary builders enforce them at runtime, if at all, because .build() is always callable and the type system never learns which inputs were mandatory.
rust-tool-base uses typestate builders generated by bon. The builder’s type changes as you set fields, and .build() only exists once every required field is present. Forgetting one is a compile error that names the missing call, not a runtime surprise. The required-versus-optional distinction stops being a comment and becomes part of the type.