type-state-builder 0.5.1

Type-state builder pattern derive macro with compile-time safety and enhanced ergonomics.
Documentation

TypeStateBuilder

Crates.io Documentation CI Rust License: MIT OR Apache-2.0

A Rust derive macro that generates compile-time safe builders using the type-state pattern. It prevents incomplete object construction by making missing required fields a compile-time error rather than a runtime failure.

Table of Contents

Introduction

Traditional builder patterns in Rust typically validate required fields at runtime, returning Result or panicking when fields are missing:

// Traditional builder - fails at runtime
let user = UserBuilder::new()
    .name("Alice")
    // Forgot to set email
    .build(); // Returns Err or panics at runtime

TypeStateBuilder moves this validation to compile time:

// TypeStateBuilder - fails at compile time
let user = User::builder()
    .name("Alice".to_string())
    // Forgot to set email
    .build(); // Compile error: method `build` not found

The compiler error message clearly indicates what is missing:

error[E0599]: no method named `build` found for struct
              `UserBuilder_HasName_MissingEmail` in the current scope

Design Philosophy

TypeStateBuilder was designed with AI-assisted development in mind. Two principles guided its design:

Compiler-Enforced Correctness

In AI-assisted development, code generation happens rapidly. LLMs can produce syntactically correct code that nonetheless contains logical errors, such as forgetting to initialize required fields. By encoding field requirements in the type system, TypeStateBuilder ensures that such errors are caught immediately by the compiler rather than manifesting as runtime failures.

The type system becomes a safety net: if the code compiles, the builder is correctly configured.

Actionable Error Messages

Many type-state builder implementations use generic type parameters to track field states:

// Other implementations might generate something like:
UserBuilder<Set, Unset, Set, Unset>
//          ^    ^     ^    ^
//          What do these mean?

When a required field is missing, the resulting error message requires decoding which type parameter corresponds to which field.

TypeStateBuilder takes a different approach. It generates a separate struct for each possible state, with the struct name explicitly describing which fields have been set and which are missing:

UserBuilder_HasName_HasEmail        // Both fields set - build() available
UserBuilder_HasName_MissingEmail    // Name set, email missing
UserBuilder_MissingName_HasEmail    // Email set, name missing
UserBuilder_MissingName_MissingEmail // Neither field set

When an AI assistant encounters an error like UserBuilder_HasName_MissingEmail doesn't have method build, it can immediately understand that the email field needs to be set. No documentation lookup or type parameter decoding is required.

Trade-offs

This approach generates more structs than a type-parameter-based solution, which increases compile time slightly. However, the improved error message clarity is worth this cost, particularly in AI-assisted workflows where rapid iteration and clear feedback are essential.

Importantly, there is no runtime cost. Rust's zero-cost abstractions ensure that the generated code is as efficient as a hand-written builder.

Installation

Add TypeStateBuilder to your Cargo.toml:

[dependencies]
type-state-builder = "0.5.1"

no_std Support

TypeStateBuilder is compatible with no_std environments. The generated code uses only core types (core::option::Option, core::marker::PhantomData, etc.) and does not require the standard library.

Minimum Supported Rust Version

TypeStateBuilder requires Rust 1.70.0 or later.

Quick Start

use type_state_builder::TypeStateBuilder;

#[derive(TypeStateBuilder, Debug)]
struct User {
    #[builder(required)]
    name: String,

    #[builder(required)]
    email: String,

    age: Option<u32>,
}

fn main() {
    // All required fields must be set before build() is available
    let user = User::builder()
        .name("Alice".to_string())
        .email("alice@example.com".to_string())
        .age(Some(30))
        .build();

    println!("{:?}", user);

    // Optional fields can be omitted
    let user2 = User::builder()
        .name("Bob".to_string())
        .email("bob@example.com".to_string())
        .build();

    println!("{:?}", user2);
}

Core Concepts

Required vs Optional Fields

Fields marked with #[builder(required)] must be set before the build() method becomes available. All other fields are optional and will use their default values if not set.

use type_state_builder::TypeStateBuilder;

#[derive(TypeStateBuilder)]
struct Config {
    #[builder(required)]
    api_key: String,          // Must be set

    timeout: Option<u32>,     // Optional, defaults to None
    retries: u32,             // Optional, defaults to 0
}

Builder Pattern Selection

TypeStateBuilder automatically selects the appropriate builder pattern based on your struct:

  • Type-state builder: When the struct has required fields. The build() method is only available after all required fields are set.
  • Regular builder: When all fields are optional. The build() method is available immediately.

State Transitions

Each setter method returns a new builder type that reflects the updated state. For a struct with required fields name and email:

User::builder()                           // UserBuilder_MissingName_MissingEmail
    .name("Alice".to_string())            // UserBuilder_HasName_MissingEmail
    .email("alice@example.com".to_string()) // UserBuilder_HasName_HasEmail
    .build()                              // User

Optional fields can be set at any point without affecting the type-state progression.

Features

Required Fields

Mark fields as required using the required attribute:

use type_state_builder::TypeStateBuilder;

#[derive(TypeStateBuilder)]
struct DatabaseConfig {
    #[builder(required)]
    host: String,

    #[builder(required)]
    database: String,

    port: u16,  // Optional, defaults to 0
}

let config = DatabaseConfig::builder()
    .host("localhost".to_string())
    .database("myapp".to_string())
    .build();

Default Values

Provide custom default values for optional fields:

use type_state_builder::TypeStateBuilder;

#[derive(TypeStateBuilder)]
struct ServerConfig {
    #[builder(required)]
    host: String,

    #[builder(default = 8080)]
    port: u16,

    #[builder(default = 30)]
    timeout_seconds: u32,

    #[builder(default = String::from("production"))]
    environment: String,
}

let config = ServerConfig::builder()
    .host("localhost".to_string())
    // port defaults to 8080
    // timeout_seconds defaults to 30
    // environment defaults to "production"
    .build();

Skip Setter

Some fields should only use their default value without exposing a setter:

use type_state_builder::TypeStateBuilder;

#[derive(TypeStateBuilder)]
struct Document {
    #[builder(required)]
    title: String,

    #[builder(default = generate_id(), skip_setter)]
    id: String,

    #[builder(default = now(), skip_setter)]
    created_at: u64,
}

fn generate_id() -> String {
    "doc-123".to_string()
}

fn now() -> u64 {
    1234567890
}

let doc = Document::builder()
    .title("My Document".to_string())
    // id and created_at are set automatically, no setters available
    .build();

Custom Setter Names

Customize individual setter method names:

use type_state_builder::TypeStateBuilder;

#[derive(TypeStateBuilder)]
struct Person {
    #[builder(required, setter_name = "full_name")]
    name: String,

    #[builder(setter_name = "years_old")]
    age: Option<u32>,
}

let person = Person::builder()
    .full_name("Alice Smith".to_string())
    .years_old(Some(30))
    .build();

Setter Prefixes

Add consistent prefixes to setter methods at the struct or field level:

use type_state_builder::TypeStateBuilder;

#[derive(TypeStateBuilder)]
#[builder(setter_prefix = "with_")]
struct ApiClient {
    #[builder(required)]
    base_url: String,

    #[builder(required, setter_prefix = "set_")]  // Overrides struct-level prefix
    api_key: String,

    timeout: Option<u32>,
}

let client = ApiClient::builder()
    .with_base_url("https://api.example.com".to_string())
    .set_api_key("secret123".to_string())  // Uses field-level prefix
    .with_timeout(Some(5000))
    .build();

Ergonomic Conversions with impl_into

The impl_into attribute generates setters that accept impl Into<T>, allowing more ergonomic API usage:

use type_state_builder::TypeStateBuilder;

#[derive(TypeStateBuilder)]
#[builder(impl_into)]  // Apply to all fields
struct Config {
    #[builder(required)]
    name: String,

    #[builder(required)]
    description: String,

    #[builder(impl_into = false)]  // Override: require exact type
    id: Option<u64>,
}

let config = Config::builder()
    .name("MyApp")                    // &str converts to String via Into
    .description("An application")    // &str converts to String via Into
    .id(Some(42u64))                  // Requires Option<u64> exactly
    .build();

Custom Conversions with converter

The converter attribute provides custom transformation logic for setters:

use type_state_builder::TypeStateBuilder;

#[derive(TypeStateBuilder, Debug)]
struct User {
    #[builder(required, converter = |email: &str| email.trim().to_lowercase())]
    email: String,

    #[builder(converter = |tags: &str| tags.split(',').map(|s| s.trim().to_string()).collect())]
    interests: Vec<String>,

    #[builder(converter = |value: &str| Some(value.to_string()))]
    nickname: Option<String>,
}

let user = User::builder()
    .email("  ALICE@EXAMPLE.COM  ")       // Normalized to "alice@example.com"
    .interests("rust, programming, web")  // Parsed to Vec<String>
    .nickname("ally")                     // Wrapped in Some
    .build();

assert_eq!(user.email, "alice@example.com");
assert_eq!(user.interests, vec!["rust", "programming", "web"]);
assert_eq!(user.nickname, Some("ally".to_string()));

The converter must be a closure expression with an explicitly typed parameter:

// Correct
#[builder(converter = |value: &str| value.to_uppercase())]

// Incorrect - function references are not supported
#[builder(converter = str::to_uppercase)]

Custom Build Method Name

Customize the name of the final build method:

use type_state_builder::TypeStateBuilder;

#[derive(TypeStateBuilder)]
#[builder(build_method = "create")]
struct Connection {
    #[builder(required)]
    host: String,
}

let conn = Connection::builder()
    .host("localhost".to_string())
    .create();  // Uses custom method name

Generics and Lifetimes

TypeStateBuilder supports generic types, lifetime parameters, and complex bounds:

use type_state_builder::TypeStateBuilder;

#[derive(TypeStateBuilder, Debug)]
struct Container<'a, T, U>
where
    T: Clone + Send,
    U: std::fmt::Debug,
{
    #[builder(required)]
    data: T,

    #[builder(required)]
    metadata: U,

    reference: Option<&'a str>,
}

let text = "referenced text";

let container = Container::<String, i32>::builder()
    .data("Hello".to_string())
    .metadata(42)
    .reference(Some(text))
    .build();

Const Builders

The #[builder(const)] attribute generates const fn builder methods, enabling compile-time constant construction. This is useful for embedded systems, static configuration, and other scenarios where values must be known at compile time.

use type_state_builder::TypeStateBuilder;

#[derive(TypeStateBuilder, Debug, PartialEq)]
#[builder(const)]
struct Config {
    #[builder(required)]
    name: &'static str,

    #[builder(required)]
    version: u32,

    #[builder(default = 8080)]
    port: u16,
}

// Compile-time constant construction
const APP_CONFIG: Config = Config::builder()
    .name("my-app")
    .version(1)
    .port(3000)
    .build();

// Also works in static context
static DEFAULT_CONFIG: Config = Config::builder()
    .name("default")
    .version(0)
    .build();

// And in const fn
const fn make_config(name: &'static str) -> Config {
    Config::builder()
        .name(name)
        .version(1)
        .build()
}

const CUSTOM: Config = make_config("custom");

Requirements for const builders:

  • Explicit defaults required: Optional fields must use #[builder(default = expr)] because Default::default() cannot be called in const context
  • No impl_into: The impl_into attribute is incompatible with const builders because trait bounds are not supported in const fn
  • Const-compatible types: Field types must support const construction (e.g., &'static str instead of String, arrays instead of Vec)

Converters with const builders:

Closure converters work with const builders. The macro automatically generates a const fn from the closure body:

use type_state_builder::TypeStateBuilder;

#[derive(TypeStateBuilder, Debug, PartialEq)]
#[builder(const)]
struct Data {
    #[builder(required, converter = |s: &'static str| s.len())]
    name_length: usize,

    #[builder(default = 0, converter = |n: i32| n * 2)]
    doubled: i32,
}

const DATA: Data = Data::builder()
    .name_length("hello")  // Converted to 5
    .doubled(21)           // Converted to 42
    .build();

Builder Method Entry Point

The #[builder(builder_method)] attribute makes a required field's setter the entry point to the builder, replacing the builder() method. This provides a more ergonomic API when one field is the natural starting point.

use type_state_builder::TypeStateBuilder;

#[derive(TypeStateBuilder, Debug, PartialEq)]
struct User {
    #[builder(required, builder_method)]
    id: u64,
    #[builder(required, impl_into)]
    name: String,
}

// Instead of User::builder().id(1).name("Alice").build()
let user = User::id(1).name("Alice").build();

assert_eq!(user.id, 1);
assert_eq!(user.name, "Alice".to_string());

Requirements:

  • Only one field per struct can have builder_method
  • The field must be required (not optional)
  • Cannot be combined with skip_setter

With const builders:

use type_state_builder::TypeStateBuilder;

#[derive(TypeStateBuilder, Debug, PartialEq)]
#[builder(const)]
struct Config {
    #[builder(required, builder_method)]
    name: &'static str,
    #[builder(default = 0)]
    version: u32,
}

const APP: Config = Config::name("myapp").version(1).build();

Understanding Error Messages

When a required field is missing, the compiler error includes the builder's type name, which explicitly states the current state:

error[E0599]: no method named `build` found for struct
              `ConfigBuilder_MissingApi_key_MissingEndpoint` in the current scope

The naming pattern is:

{StructName}Builder_{FieldState1}_{FieldState2}_...

Where each field state is either:

  • Has{FieldName} - the field has been set
  • Missing{FieldName} - the field has not been set

For example, with a struct having fields api_key and endpoint:

State Type Name
Neither set ConfigBuilder_MissingApi_key_MissingEndpoint
Only api_key set ConfigBuilder_HasApi_key_MissingEndpoint
Only endpoint set ConfigBuilder_MissingApi_key_HasEndpoint
Both set ConfigBuilder_HasApi_key_HasEndpoint

The build() method is only available on the final state where all required fields are set.

Compatibility

no_std Support

TypeStateBuilder generates code that is compatible with no_std environments. The generated code uses:

  • core::option::Option instead of std::option::Option
  • core::marker::PhantomData instead of std::marker::PhantomData
  • core::fmt::Debug instead of std::fmt::Debug
  • core::default::Default instead of std::default::Default

No feature flags are required; no_std compatibility is the default.

Minimum Supported Rust Version

TypeStateBuilder requires Rust 1.70.0 or later. This requirement is driven by:

  • Stable proc-macro features used in code generation
  • Advanced generic parameter handling

The MSRV is tested in CI and will not be increased without a minor version bump.

How It Works

TypeStateBuilder implements the type-state pattern using Rust's type system to encode state at compile time.

Generated Types

For a struct with required fields, the macro generates:

  1. Multiple builder structs, one for each possible combination of set/unset required fields
  2. Setter methods that transition between states
  3. A build() method only on the final state

State Encoding

Each required field contributes to the builder's type name. With n required fields, there are 2^n possible states. The macro generates a struct for each state, though in practice many states are not reachable through normal usage.

Zero Runtime Cost

The state tracking is entirely compile-time:

  • No runtime state variable
  • No runtime validation
  • No Option wrappers for required fields internally
  • The final build() call simply moves values into the target struct

The generated code is equivalent to what you would write by hand, with the type system providing the safety guarantees.

Example Generation

For this input:

#[derive(TypeStateBuilder)]
struct User {
    #[builder(required)]
    name: String,
    age: Option<u32>,
}

The macro generates (simplified):

struct UserBuilder_MissingName {
    age: Option<u32>,
}

struct UserBuilder_HasName {
    name: String,
    age: Option<u32>,
}

impl UserBuilder_MissingName {
    fn name(self, value: String) -> UserBuilder_HasName {
        UserBuilder_HasName {
            name: value,
            age: self.age,
        }
    }

    fn age(self, value: Option<u32>) -> Self {
        Self { age: value, ..self }
    }
}

impl UserBuilder_HasName {
    fn age(self, value: Option<u32>) -> Self {
        Self { age: value, ..self }
    }

    fn build(self) -> User {
        User {
            name: self.name,
            age: self.age,
        }
    }
}

License

Licensed under either of Apache License, Version 2.0 or MIT license at your option.

Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this crate by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.

Contributing

Contributions are welcome. Please open an issue to discuss significant changes before submitting a pull request.

For bug reports and feature requests, use the issue tracker.