oas3-gen
A Rust code generator for OpenAPI v3.1.x specifications.
Overview
oas3-gen parses OpenAPI 3.1 specifications and generates comprehensive Rust type
definitions with validation. It produces idiomatic Rust code with full serde
support, making it easy to integrate with HTTP clients and servers.
Features
- Type Generation: Structs, enums, and type aliases from OpenAPI schemas
- HTTP Client: Generated async client using
reqwest - Axum Server: Generated server trait and router for
axum - Validation: Field validation using the
validatorcrate - Serde Support: Full serialization/deserialization with
serde - Discriminated Unions: Proper handling of
oneOf/anyOfwith discriminators - Builder Pattern: Optional
bonintegration for ergonomic struct construction via--enable-builders
Installation
cargo install oas3-gen
Or build from source:
git clone https://github.com/eklipse2k8/oas3-gen
cd oas3-gen
cargo build --release
Quick Start
Generate types from an OpenAPI specification:
oas3-gen generate types -i api.json -o types.rs
Generate a complete HTTP client:
oas3-gen generate client -i api.json -o client.rs
Generate a modular client library:
oas3-gen generate client-mod -i api.json -o src/api/
Generate an Axum server trait:
oas3-gen generate server-mod -i api.json -o src/server/
Supported Formats
- JSON specifications (
.json) - YAML specifications (
.yaml,.yml)
Requirements
- Rust 1.89 or later
- OpenAPI 3.1.x specification
License
MIT
Code Generation Reference
This document describes all command-line flags that control code generation behavior
in oas3-gen. Each flag affects the structure, visibility, or content of generated
Rust code.
Table of Contents
- Generation Modes
- Visibility
- Enum Mode
- Helper Methods
- OData Support
- Type Customization
- Operation Filtering
- Schema Filtering
- Header Emission
- Builder Generation
- Documentation Formatting
Generation Modes
The positional mode argument determines what code is generated.
cargo run -- generate <MODE> -i spec.json -o <OUTPUT>
types
Generates type definitions only. Output is a single file.
Output: types.rs
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Pet {
pub id: i64,
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Status {
#[serde(rename = "available")]
Available,
#[serde(rename = "pending")]
Pending,
}
client
Generates types and HTTP client in a single file. Requires reqwest.
Output: client.rs
// Types section
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Pet { /* ... */ }
// Client section
#[derive(Debug, Clone)]
pub struct PetStoreClient {
pub client: Client,
pub base_url: Url,
}
impl PetStoreClient {
pub fn new() -> Self { /* ... */ }
pub async fn list_pets(&self, request: ListPetsRequest) -> anyhow::Result<ListPetsResponse> {
/* ... */
}
}
client-mod
Generates a module directory with separate files for types and client.
Output directory:
output/
├── mod.rs
├── types.rs
└── client.rs
mod.rs:
mod types;
mod client;
pub use types::*;
pub use client::*;
server-mod
Generates a module directory with types and an Axum server trait.
Output directory:
output/
├── mod.rs
├── types.rs
└── server.rs
server.rs:
pub trait ApiServer: Send + Sync {
fn list_pets(
&self,
request: ListPetsRequest,
) -> impl std::future::Future<Output = anyhow::Result<ListPetsResponse>> + Send;
}
pub fn router<S>(service: S) -> Router
where
S: ApiServer + Clone + Send + Sync + 'static,
{
Router::new()
.route("/pets", get(list_pets::<S>))
.with_state(service)
}
Visibility
-C, --visibility <LEVEL>
Controls the visibility modifier applied to all generated items.
| Value | Modifier | Use Case |
|---|---|---|
public (default) | pub | Library distribution |
crate | pub(crate) | Internal crate types |
file | (none) | Private implementation |
Example: --visibility public
pub struct Pet {
pub id: i64,
pub name: String,
}
pub enum Status {
Available,
Pending,
}
impl Status {
pub fn available() -> Self { Self::Available }
}
Example: --visibility crate
pub(crate) struct Pet {
pub(crate) id: i64,
pub(crate) name: String,
}
pub(crate) enum Status {
Available,
Pending,
}
impl Status {
pub(crate) fn available() -> Self { Self::Available }
}
Example: --visibility file
struct Pet {
id: i64,
name: String,
}
enum Status {
Available,
Pending,
}
impl Status {
fn available() -> Self { Self::Available }
}
Enum Mode
--enum-mode <MODE>
Controls how enum variants with case-only differences are handled.
| Value | Behavior |
|---|---|
merge (default) | Merge duplicates; first occurrence is canonical, others become aliases |
preserve | Keep all variants; append numeric suffix to collisions |
relaxed | Merge duplicates; enable case-insensitive deserialization |
Input Schema
{
"type": "string",
"enum": ["ACTIVE", "active", "Active", "PENDING"]
}
Example: --enum-mode merge
Variants normalizing to the same identifier are merged. Additional values become serde aliases.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Status {
#[serde(rename = "ACTIVE", alias = "active", alias = "Active")]
Active,
#[serde(rename = "PENDING")]
Pending,
}
Deserialization:
"ACTIVE"→Status::Active"active"→Status::Active"Active"→Status::Active"pending"→ Error (case-sensitive)
Example: --enum-mode preserve
Each JSON value becomes a distinct variant. Colliding names receive numeric suffixes.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Status {
#[serde(rename = "ACTIVE")]
Active,
#[serde(rename = "active")]
Active1,
#[serde(rename = "Active")]
Active2,
#[serde(rename = "PENDING")]
Pending,
}
Deserialization:
"ACTIVE"→Status::Active"active"→Status::Active1"Active"→Status::Active2
Example: --enum-mode relaxed
Generates a custom Deserialize implementation that normalizes input to lowercase
before matching.
#[derive(Debug, Clone, Serialize)]
pub enum Status {
#[serde(rename = "ACTIVE")]
Active,
#[serde(rename = "PENDING")]
Pending,
}
impl<'de> serde::Deserialize<'de> for Status {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
match s.to_ascii_lowercase().as_str() {
"active" => Ok(Self::Active),
"pending" => Ok(Self::Pending),
_ => Err(serde::de::Error::unknown_variant(&s, &["active", "pending"])),
}
}
}
Deserialization:
"ACTIVE"→Status::Active"active"→Status::Active"Active"→Status::Active"PENDING"→Status::Pending"pending"→Status::Pending
Helper Methods
--no-helpers
Disables generation of ergonomic constructor methods for enum variants.
Helper methods are generated for variants that wrap structs with default implementations. They allow constructing variants with minimal boilerplate.
Default (helpers enabled)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ContentBlock {
Text(TextBlock),
Image(ImageBlock),
Code(CodeBlock),
}
impl ContentBlock {
pub fn text(text: String) -> Self {
Self::Text(TextBlock {
text,
..Default::default()
})
}
pub fn image(source: Box<ImageSource>) -> Self {
Self::Image(ImageBlock {
source,
..Default::default()
})
}
pub fn code(code: String) -> Self {
Self::Code(CodeBlock {
code,
..Default::default()
})
}
}
Usage:
let block = ContentBlock::text("Hello, world!".to_string());
With --no-helpers
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ContentBlock {
Text(TextBlock),
Image(ImageBlock),
Code(CodeBlock),
}
// No impl block generated
Usage:
let block = ContentBlock::Text(TextBlock {
text: "Hello, world!".to_string(),
..Default::default()
});
OData Support
--odata-support
Enables OData-specific field optionality rules. Fields starting with @odata.
are made optional even when listed in the schema’s required array.
This accommodates Microsoft Graph and other OData APIs where metadata fields are declared required but frequently omitted in responses.
Input Schema
{
"type": "object",
"properties": {
"id": { "type": "string" },
"@odata.type": { "type": "string" },
"@odata.id": { "type": "string" }
},
"required": ["id", "@odata.type", "@odata.id"]
}
Default (OData support disabled)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Entity {
pub id: String,
#[serde(rename = "@odata.type")]
pub odata_type: String,
#[serde(rename = "@odata.id")]
pub odata_id: String,
}
Deserialization fails if @odata.type or @odata.id are missing from the response.
With --odata-support
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Entity {
pub id: String,
#[serde(rename = "@odata.type")]
pub odata_type: Option<String>,
#[serde(rename = "@odata.id")]
pub odata_id: Option<String>,
}
Deserialization succeeds when OData metadata fields are absent.
Constraints: OData optionality only applies when:
- Field name starts with
@odata. - Parent schema has no discriminator
- Parent schema is not an intersection type
Type Customization
-c, --customize <TYPE=PATH>
Overrides the default type mapping for specific primitive types. Uses serde_with
for custom serialization.
| Key | OpenAPI Format | Default Type |
|---|---|---|
date_time | date-time | chrono::DateTime<Utc> |
date | date | chrono::NaiveDate |
time | time | chrono::NaiveTime |
duration | duration | std::time::Duration |
uuid | uuid | uuid::Uuid |
Multiple customizations can be specified:
cargo run -- generate types -i spec.json -o types.rs \
-c date_time=time::OffsetDateTime \
-c date=time::Date \
-c uuid=my_crate::CustomUuid
Default (no customization)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Event {
pub created_at: chrono::DateTime<chrono::Utc>,
pub scheduled_date: chrono::NaiveDate,
}
With -c date_time=time::OffsetDateTime
#[serde_with::serde_as]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Event {
#[serde_as(as = "time::OffsetDateTime")]
pub created_at: time::OffsetDateTime,
pub scheduled_date: chrono::NaiveDate,
}
Handling Optional and Array Fields
Customizations automatically wrap in Option<> and Vec<> as needed:
#[serde_with::serde_as]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Schedule {
#[serde_as(as = "time::OffsetDateTime")]
pub start: time::OffsetDateTime,
#[serde_as(as = "Option<time::OffsetDateTime>")]
pub end: Option<time::OffsetDateTime>,
#[serde_as(as = "Vec<time::OffsetDateTime>")]
pub milestones: Vec<time::OffsetDateTime>,
#[serde_as(as = "Option<Vec<time::OffsetDateTime>>")]
pub optional_dates: Option<Vec<time::OffsetDateTime>>,
}
Operation Filtering
--only <id1,id2,...>
--exclude <id1,id2,...>
Filters which operations are included in generated client or server code. These flags are mutually exclusive.
--only
Generates code only for the specified operation IDs. All other operations are excluded.
cargo run -- generate client-mod -i petstore.json -o output/ \
--only listPets,createPet
Generated client:
impl PetStoreClient {
pub async fn list_pets(&self, request: ListPetsRequest) -> anyhow::Result<ListPetsResponse> {
/* ... */
}
pub async fn create_pet(&self, request: CreatePetRequest) -> anyhow::Result<CreatePetResponse> {
/* ... */
}
// No other methods generated
}
--exclude
Generates code for all operations except the specified IDs.
cargo run -- generate client-mod -i petstore.json -o output/ \
--exclude deletePet
Generated client:
impl PetStoreClient {
pub async fn list_pets(&self, ...) -> ... { /* ... */ }
pub async fn create_pet(&self, ...) -> ... { /* ... */ }
pub async fn get_pet(&self, ...) -> ... { /* ... */ }
pub async fn update_pet(&self, ...) -> ... { /* ... */ }
// delete_pet NOT generated
}
Schema Dependency Resolution
When filtering operations, schemas are automatically included based on transitive dependencies:
- Collect all schemas referenced by selected operations (parameters, request bodies, responses)
- Expand to include all schemas those schemas depend on
- Generate only the resulting set of types
Example: If listPets returns Pet[] and Pet contains a Category field,
both Pet and Category are generated even though only listPets was selected.
Schema Filtering
--all-schemas
By default, only schemas reachable from selected operations are generated. This flag overrides that behavior to generate all schemas defined in the specification.
Default (reachability filtering)
Given a spec with schemas Pet, Category, Store, Inventory where only
Pet and Category are used by any operation:
cargo run -- generate types -i spec.json -o types.rs
Generated: Pet, Category
Skipped: Store, Inventory (reported as orphaned schemas)
With --all-schemas
cargo run -- generate types -i spec.json -o types.rs --all-schemas
Generated: Pet, Category, Store, Inventory
Combining with Operation Filtering
The --all-schemas flag is in the same argument group as --only and --exclude.
You cannot combine them directly.
To generate all schemas while filtering operations, generate types separately:
# Generate all types
cargo run -- generate types -i spec.json -o types.rs --all-schemas
# Generate filtered client (will only include operation-referenced types inline)
cargo run -- generate client -i spec.json -o client.rs --only listPets
Header Emission
--all-headers
By default, header constants are only generated for headers that appear as parameters
in selected operations. The --all-headers flag additionally emits constants for
all header parameters defined in components/parameters, even if no operation
references them.
Default (operation-referenced headers only)
Given a spec with x-api-version used in an operation and x-api-key defined
in components/parameters but not referenced by any operation:
cargo run -- generate types -i spec.json -o types.rs
Generated:
pub const X_API_VERSION: http::HeaderName = http::HeaderName::from_static("x-api-version");
x-api-key is not emitted because no operation uses it.
With --all-headers
cargo run -- generate types -i spec.json -o types.rs --all-headers
Generated:
pub const X_API_VERSION: http::HeaderName = http::HeaderName::from_static("x-api-version");
pub const X_API_KEY: http::HeaderName = http::HeaderName::from_static("x-api-key");
Builder Generation
--enable-builders
Enables bon::Builder derives on schema structs and #[builder] constructor
methods on request structs.
When disabled (the default), no bon attributes are emitted in generated code.
Default (builders disabled)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Pet {
pub id: i64,
pub name: String,
}
pub struct CreatePetRequest {
pub path: CreatePetRequestPath,
}
With --enable-builders
#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
pub struct Pet {
pub id: i64,
pub name: String,
}
pub struct CreatePetRequest {
pub path: CreatePetRequestPath,
}
#[bon::bon]
impl CreatePetRequest {
#[builder]
pub fn new(/* params */) -> anyhow::Result<Self> {
/* ... */
}
}
Documentation Formatting
--doc-format
Enables formatting of generated documentation comments using the external
mdformat CLI tool. When enabled,
documentation text from OpenAPI description and summary fields is piped
through mdformat with line wrapping at 100 characters.
This requires mdformat to be installed and available on PATH.
pip install mdformat
Default (formatting disabled)
Documentation text is passed through as-is from the OpenAPI specification, with only escaped newline normalization applied.
/// A long description that may contain very long lines that extend well beyond typical line widths because the OpenAPI spec author did not wrap them.
pub struct Widget {
pub id: i64,
}
With --doc-format
cargo run -- generate types -i spec.json -o types.rs --doc-format
Documentation text is reformatted with consistent line wrapping:
/// A long description that may contain very long lines that extend well beyond
/// typical line widths because the OpenAPI spec author did not wrap them.
pub struct Widget {
pub id: i64,
}
Flag Summary
| Flag | Default | Description |
|---|---|---|
mode | types | Generation mode: types, client, client-mod, server-mod |
-C, --visibility | public | Item visibility: public, crate, file |
--enum-mode | merge | Enum duplicate handling: merge, preserve, relaxed |
--no-helpers | false | Disable enum constructor helpers |
--odata-support | false | Make @odata.* fields optional |
-c, --customize | (none) | Custom type mapping (repeatable) |
--all-headers | false | Emit header constants for all component-level headers |
--enable-builders | false | Enable bon builder derives and methods |
--doc-format | false | Format doc comments with mdformat |
--only | (none) | Include only specified operations |
--exclude | (none) | Exclude specified operations |
--all-schemas | false | Generate all schemas regardless of usage |
Builder Pattern with bon
Generated Rust types from OpenAPI schemas tend to have many fields. Some are required, some are optional, and some have defaults that only matter during deserialization. When constructing these types by hand in application code, struct literal syntax can become verbose and error-prone fast.
Consider a Pet with four fields:
let pet = Pet {
id: 42,
name: "Whiskers".to_string(),
tag: None,
allergies: None,
};
That’s manageable. Now consider a request struct with path parameters, query parameters, and headers that all need to be slotted into nested sub-structs:
let request = ListPetsRequest {
path: ListPetsRequestPath {
api_version: "v2".to_string(),
},
query: ListPetsRequestQuery {
limit: Some(25),
},
header: ListPetsRequestHeader {
x_sort_order: None,
x_only: None,
},
};
That is six lines of ceremony to express two meaningful values. The nested struct names are long, the optional fields are noise, and the compiler will not catch a missing field until the developer adds it. As schemas grow, this pattern scales poorly.
The --enable-builders flag solves this by integrating the
bon crate into the generated code. bon
is a compile-time builder generator that uses the typestate pattern to ensure
all required fields are set before construction, with zero runtime cost. It
turns the example above into:
let request = ListPetsRequest::builder()
.api_version("v2".to_string())
.limit(25)
.build()?;
Three lines. No nested structs. No None assignments. Required fields are
enforced at compile time, and optional fields can simply be omitted.
Enabling Builders
Pass the --enable-builders flag during code generation:
oas3-gen generate client-mod -i api.json -o src/api/ --enable-builders
This works with all generation modes: types, client, client-mod, and
server-mod.
Adding bon to Your Project
The generated code references bon macros and derives, so the crate must be
present in the consuming project’s Cargo.toml:
[dependencies]
bon = "3.8"
Without this dependency, the generated code will fail to compile with unresolved
import errors. The bon crate is lightweight and has no runtime dependencies
beyond proc-macro2 and syn, which most Rust projects already pull in
transitively.
Tip: If the project already uses
bonfor its own types, there is nothing extra to add. The generated code uses the samebon::Builderderive and#[builder]attribute that any hand-writtenbonusage would.
What Changes in the Generated Code
Enabling builders affects two categories of generated types: schema structs
and request structs. Each gets a different integration point with bon.
Schema Structs
Schema structs (types generated from components/schemas) receive a
bon::Builder derive. This adds a ::builder() associated function that
returns a type-safe builder.
Without --enable-builders:
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct Pet {
pub id: i64,
pub name: String,
pub tag: Option<String>,
pub allergies: Option<Box<Health>>,
}
With --enable-builders:
#[derive(Debug, Clone, PartialEq, Deserialize, bon::Builder)]
pub struct Pet {
pub id: i64,
pub name: String,
pub tag: Option<String>,
pub allergies: Option<Box<Health>>,
}
The struct definition itself is identical except for the extra derive. The difference shows up at the call site:
// Struct literal (always available)
let pet = Pet {
id: 42,
name: "Whiskers".to_string(),
tag: Some("indoor".to_string()),
allergies: None,
};
// Builder (available with --enable-builders)
let pet = Pet::builder()
.id(42)
.name("Whiskers".to_string())
.tag("indoor".to_string())
.build();
Notice that tag accepts a plain String rather than Option<String>. The
builder treats Option fields as optional setters: calling .tag(...) wraps
the value in Some automatically, and omitting the call leaves it as None.
The same applies to allergies, which can simply be left off when not needed.
Request Structs
Request structs benefit from builders in a more dramatic way. These types contain nested sub-structs for path parameters, query parameters, and headers. Without builders, constructing a request means manually assembling each nested struct.
When --enable-builders is active, the generator produces a #[builder]
constructor method that flattens all parameters into a single builder
interface. The nested structs are assembled internally.
Without --enable-builders:
pub struct ListPetsRequest {
pub path: ListPetsRequestPath,
pub query: ListPetsRequestQuery,
pub header: ListPetsRequestHeader,
}
// Construction requires knowledge of internal structure
let request = ListPetsRequest {
path: ListPetsRequestPath {
api_version: "v2".to_string(),
},
query: ListPetsRequestQuery {
limit: Some(25),
},
header: ListPetsRequestHeader {
x_sort_order: Some(ListCatsRequestHeaderXSortOrder::Asc),
x_only: None,
},
};
With --enable-builders:
pub struct ListPetsRequest {
pub path: ListPetsRequestPath,
pub query: ListPetsRequestQuery,
pub header: ListPetsRequestHeader,
}
#[bon::bon]
impl ListPetsRequest {
#[builder]
pub fn new(
api_version: String,
limit: Option<i32>,
x_sort_order: Option<ListCatsRequestHeaderXSortOrder>,
x_only: Option<Vec<ListPetsRequestHeaderXonly>>,
) -> anyhow::Result<Self> {
let request = Self {
path: ListPetsRequestPath { api_version },
query: ListPetsRequestQuery { limit },
header: ListPetsRequestHeader {
x_sort_order,
x_only,
},
};
request.validate()?;
Ok(request)
}
}
// Construction is flat and ergonomic
let request = ListPetsRequest::builder()
.api_version("v2".to_string())
.limit(25)
.x_sort_order(ListCatsRequestHeaderXSortOrder::Asc)
.build()?;
Several things are worth noting here:
- Flat parameter list. Path, query, and header parameters are all promoted to top-level builder setters. There is no need to know which sub-struct a parameter belongs to.
- Optional fields omitted.
x_onlyis not set, so it defaults toNone. No explicitNoneassignment required. - Validation included. The builder’s
build()call runs the samevalidator::Validatechecks that would normally need to be invoked manually. If a required field violates a constraint (for example, a string shorter than its minimum length), the builder returns an error.
A Side-by-Side Comparison
To see the full impact, consider a ShowPetByIdRequest that takes a path
parameter and a required header:
Without Builders
let request = ShowPetByIdRequest {
path: ShowPetByIdRequestPath {
pet_id: "pet-123".to_string(),
},
header: ShowPetByIdRequestHeader {
x_api_version: "2024-01-01".to_string(),
},
};
request.validate()?;
The developer must remember to call .validate() separately. Forgetting it
means constraints like minimum string length go unchecked at runtime.
With Builders
let request = ShowPetByIdRequest::builder()
.pet_id("pet-123".to_string())
.x_api_version("2024-01-01".to_string())
.build()?;
Validation is automatic. The required fields pet_id and x_api_version are
enforced at compile time by the builder’s typestate. Calling .build() without
setting them is a compilation error.
When Builders Shine
Builders are particularly valuable in a few common scenarios:
Testing. Test code often constructs many variations of the same struct. Builders reduce the noise so the meaningful differences stand out:
#[test]
fn test_pet_with_tag() {
let pet = Pet::builder()
.id(1)
.name("Buddy".to_string())
.tag("dog".to_string())
.build();
assert_eq!(pet.tag, Some("dog".to_string()));
}
#[test]
fn test_pet_without_tag() {
let pet = Pet::builder()
.id(2)
.name("Mittens".to_string())
.build();
assert_eq!(pet.tag, None);
}
Large schemas. Some OpenAPI specifications define schemas with dozens of
fields. Struct literals for these types become walls of field: None lines.
Builders let the developer set only what matters and leave the rest at their
defaults.
Prototyping and iteration. When a schema is still evolving, adding a new optional field does not break existing builder call sites. Struct literals, on the other hand, require updating every construction site to include the new field.
Combining with Other Flags
The --enable-builders flag composes freely with other code generation options:
oas3-gen generate client-mod -i api.json -o src/api/ \
--enable-builders \
--visibility crate \
--enum-mode relaxed \
-c date_time=time::OffsetDateTime
Builders respect the chosen visibility level. With --visibility crate, the
generated builder methods and derives remain accessible within the crate but
are not part of the public API.
Trade-offs
Every dependency is a trade-off, and bon is no exception. Here is what to
consider:
| Consideration | Details |
|---|---|
| Compile time | bon is a proc-macro crate that adds to compilation. For most projects this is negligible, but very large generated files with hundreds of structs may see a measurable increase. |
| IDE support | Builder methods are generated by macros, so some IDEs may not auto-complete them until the project is built once. After that, rust-analyzer picks them up normally. |
| Struct literals still work | Enabling builders does not remove the ability to construct types with struct literal syntax. Both approaches coexist, and developers can mix them freely. |
For projects that want the lightest possible generated output with zero extra
dependencies, leaving --enable-builders off is the right call. For projects
that prioritize developer ergonomics and are already comfortable with proc-macro
dependencies, builders pay for themselves quickly in reduced boilerplate and
fewer construction errors.