The Typestate Builder Pattern: A Case Study in Rust's Compile-Time Safety

Recently, while working on AudioNimbus (a Rust wrapper around Steam Audio, a C spatial audio library), I encountered a segfault that led me to a solution I’m calling the ‘typestate builder pattern’. This approach leverages Rust’s type system together with the builder pattern to catch API misuse at compile time rather than runtime. Here’s how it works.

The Problem: C Comes Without a Safety Net

AudioNimbus uses bindgen to generate Rust bindings for Steam Audio’s C API, then wraps those bindings to provide a more idiomatic Rust interface. The core of the library is the Simulator struct, which simulates how sound propagates through a given environment.

Here’s where things got interesting. The original API looked something like this:

pub struct Simulator(audionimbus_sys::IPLSimulator);

impl Simulator {
    pub fn try_new(
        settings: SimulationSettings,
    ) -> Result<Self, SteamAudioError> {
        // ... create simulator with settings
    }

    pub fn run_direct(&self) {
        unsafe { audionimbus_sys::iplSimulatorRunDirect(self.raw_ptr()) }
    }

    pub fn run_reflections(&self) {
        unsafe { audionimbus_sys::iplSimulatorRunReflections(self.raw_ptr()) }
    }

    pub fn run_pathing(&self) {
        unsafe { audionimbus_sys::iplSimulatorRunPathing(self.raw_ptr()) }
    }
}

The Simulator is a simple wrapper around its C counterpart, audionimbus_sys::IPLSimulator, and forwards its method calls via FFI. Its constructor takes settings as an argument, whose fields can be set to Some to enable certain simulation features:

pub struct SimulationSettings {
    pub direct_simulation: Option<DirectSimulationSettings>,
    pub reflections_simulation: Option<ReflectionsSimulationSettings>,
    pub pathing_simulation: Option<PathingSimulationSettings>,
    // ... other fields
}

Here’s the bug: if you created a simulator with reflections_simulation: None (resp. other settings) but then called run_reflections(), the underlying C library would happily try to run reflections simulation without the required settings, leading to a segfault.

// This compiles fine but crashes at runtime
let simulator = Simulator::try_new(
  SimulationSettings {
    direct_simulation: Some(direct_settings),
    reflections_simulation: None, // Oops!
    pathing_simulation: None,
  }
)?;

simulator.run_reflections(); // Segfault

The C library doesn’t perform any validation; it assumes you know what you’re doing.

The Naive Solution: Runtime Checks

The first instinct was to add runtime validation:

impl Simulator {
    pub fn run_reflections(&self) -> Result<(), SimulationError> {
        if !self.reflections_enabled {
            return Err(SimulationError::ReflectionsDisabled);
        }
        unsafe { audionimbus_sys::iplSimulatorRunReflections(self.raw_ptr()) }
        Ok(())
    }
}

This works, but it’s not very Rust-like. We’re catching the error at runtime when we could catch it at compile time. It also delegates the responsibility of handling misconfiguration errors to the user of the library, who is now left matching on the result on every call to the simulation methods. Not great in terms of API usability.

The Rust Way: Compile-Time Safety with the Typestate Builder Pattern

We want to make it impossible to call run_reflections() on a simulator that wasn’t configured for reflections. What if the compiler could catch this mistake for us?

Enter the typestate builder pattern. The idea is to use Rust’s type system to encode the simulator’s capabilities at the type level. Here’s how it works:

Step 1: Define Capability Markers

// Marker types for capabilities
pub struct Direct;
pub struct Reflections;
pub struct Pathing;

These empty structs serve as compile-time markers. They don’t exist at runtime, they’re purely for the type system.

Step 2: Parameterize the Simulator

use std::marker::PhantomData;

pub struct Simulator<D = (), R = (), P = ()> {
    inner: audionimbus_sys::IPLSimulator,
    _direct: PhantomData<D>,
    _reflections: PhantomData<R>,
    _pathing: PhantomData<P>,
}

The Simulator is now generic over three type parameters representing its capabilities. By default, they’re all () (unit type), meaning no capabilities are enabled.

This technique on its own is known as typestate programming. Next, we couple it with a builder.

Step 3: Create a Typestate Builder

pub struct SimulatorBuilder<D = (), R = (), P = ()> {
    settings: SimulationSettings,
    _direct: PhantomData<D>,
    _reflections: PhantomData<R>,
    _pathing: PhantomData<P>,
}

impl<D, R, P> SimulatorBuilder<D, R, P> {
    pub fn try_build(self) -> Result<Simulator<D, R, P>, SteamAudioError> {
        let simulator = Simulator {
            inner: audionimbus_sys::iplSimulatorCreate(self.settings.into())?,
            _direct: PhantomData,
            _reflections: PhantomData,
            _pathing: PhantomData,
        };

        Ok(simulator)
    }
}

impl Simulator<(), (), ()> {
    pub fn builder(
      required_param: SomeRequiredParam,
    ) -> SimulatorBuilder<(), (), ()> {
        SimulatorBuilder {
            settings: SimulationSettings {
                required_param,
                direct_simulation: None,
                reflections_simulation: None,
                pathing_simulation: None,
            },
            _direct: PhantomData,
            _reflections: PhantomData,
            _pathing: PhantomData,
        }
    }
}

The builder is also generic over the same three type parameters. Notice how try_build() preserves the type parameters from the builder to the final Simulator, ensuring that the capabilities configured during building are reflected in the final type.

Also note that the settings’ required fields are passed to the builder constructor, and optional fields are initialized to None.

Step 4: Enable Capabilities Through the Builder

impl<D, R, P> SimulatorBuilder<D, R, P> {
    pub fn with_direct(
        self,
        direct_settings: DirectSimulationSettings,
    ) -> SimulatorBuilder<Direct, R, P> {
        let SimulatorBuilder {
            mut settings,
            _reflections,
            _pathing,
            ..
        } = self;

        settings.direct_simulation = Some(direct_settings);

        SimulatorBuilder {
            settings,
            _direct: PhantomData,
            _reflections,
            _pathing,
        }
    }

    pub fn with_reflections(
        self,
        reflections_settings: ReflectionsSimulationSettings,
    ) -> SimulatorBuilder<D, Reflections, P> {
        let SimulatorBuilder {
            mut settings,
            _direct,
            _pathing,
            ..
        } = self;

        settings.reflections_simulation = Some(reflections_settings);

        SimulatorBuilder {
            settings,
            _direct,
            _reflections: PhantomData,
            _pathing,
        }
    }

    // Similar implementation for with_pathing...
}

Notice how each method changes the type signature. with_reflections() transforms a SimulatorBuilder<D, R, P> into a SimulatorBuilder<D, Reflections, P>.

Step 5: Restrict Methods Based on Capabilities

impl<D, R, P> Simulator<D, R, P> {
    // Common methods available to all simulators
    pub fn set_scene(&mut self, scene: &Scene) { /* ... */ }
    pub fn commit(&mut self) { /* ... */ }
}

impl<R, P> Simulator<Direct, R, P> {
    // Only available if Direct capability is enabled
    pub fn run_direct(&self) {
        unsafe { audionimbus_sys::iplSimulatorRunDirect(self.raw_ptr()) }
    }
}

impl<D, P> Simulator<D, Reflections, P> {
    // Only available if Reflections capability is enabled
    pub fn run_reflections(&self) {
        unsafe { audionimbus_sys::iplSimulatorRunReflections(self.raw_ptr()) }
    }
}

impl<D, R> Simulator<D, R, Pathing> {
    // Only available if Pathing capability is enabled
    pub fn run_pathing(&self) {
        unsafe { audionimbus_sys::iplSimulatorRunPathing(self.raw_ptr()) }
    }
}

The Result: Compile-Time Safety

Now, the API is impossible to misuse, and the segfault is no more1:

// This won't compile: no reflections capability
let simulator = Simulator::builder(required_param)
    .with_direct(DirectSimulationSettings { /* ... */ })
    // Note: no .with_reflections() call
    .try_build()?;

simulator.run_direct(); // OK because direct simulation was enabled
simulator.run_reflections(); // Compile error

The above example yields the following compilation error:

error[E0599]: no method named `run_reflections` found for struct `Simulator<Direct, (), ()>` in the current scope

The Bottom Line

The typestate builder pattern makes it impossible to call methods on structs that weren’t configured for them.

It does that with zero runtime overhead: the pattern leverages phantom types to carry type information without runtime cost. All the type information is erased at compile time.

By encoding the C library’s implicit contracts in Rust’s type system, we get the best of both worlds: the performance of the underlying C library with the safety guarantees of Rust.

This pattern isn’t limited to FFI wrappers, however. It can be applied anywhere you have objects with different capabilities or states that affect which methods should be available. It’s a powerful technique that showcases Rust’s ability to provide zero-cost abstractions for safety and demonstrates the language’s philosophy of pushing errors leftward from runtime to compile time.

Footnotes

  1. You can see the full implementation in this pull request.