go-tool-base configures things with functional options, and if you forget a required one, the best case is a runtime failure and the worst case is an empty value sailing silently into everything downstream. Most builder patterns share the same hole. rust-tool-base closes it in a way I find genuinely delightful: the .build() method simply doesn’t exist until you’ve set every required field.
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 actually 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 quietly carries an empty name into everything downstream.
A plain builder is no better here. builder.name("mytool").build() and builder.build() are both perfectly 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’ve 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 isn’t 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();
doesn’t compile. Not because a runtime check fired, but because in the state “name not set” there’s no .build() method to call in the first place. 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 won’t produce an Application until it has metadata and a version, and “won’t” 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 at the worst possible moment. A compile-time check finds it the instant you write it, in the editor, before anything has run at all. The same mistake, caught at the cheapest possible point instead of one of the expensive ones.
It also changes what the API documents about itself. A functional-options constructor can’t 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 on the way.
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. (Another entry, if you’re keeping score from the porting post, in the column of outcomes that survived while the Go mechanism got left behind.)
The short version
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.
