Using PhantomData with the type-state builder pattern

A while back, I learned about the type-state builder pattern in Rust a while ago through https://www.youtube.com/watch?v=pwmIQzLuYl0. Recently, I found a use-case in a project I am working on:

// Type-States

#[derive(Clone)]
pub struct NonBlocking;
#[derive(Clone)]
pub struct Blocking;

#[derive(Clone)]
pub struct WithoutEnv;
#[derive(Clone)]
pub struct WithEnv {
    env_variables: Arc<Mutex<EnvVariables>>,
}

#[derive(Clone)]
pub struct NoOutput;
#[derive(Clone)]
pub struct WithOutput;

#[derive(Clone)]
pub struct NonInterruptible;
pub struct Interruptible {
    pub interrupt_rx: Receiver<InterruptSignal>,
}

// Advantages of the Type-State Builder Pattern:
// 1. We don't have any option/enum (an alternative configuration strategy)
// checking overhead at runtime.
// 2. We can guarantee that we handled all possible Command "variants"
// (combination of config options), that we use, at compile-time.
// 3. Arguably, this also results in separated, cleaner code.

/// A Command offering customization of the blocking behaviour, the input
/// environment variables, whether the output is captured and whether the
/// execution can be interrupted. Utilizes the type-state builder pattern to
/// enforce these configurations at compile-time.
#[derive(Clone)]
pub struct CommandBuilder<B = NonBlocking, E = WithoutEnv, O = NoOutput, I = NonInterruptible> {
    command: String,
    blocking: B,
    output: O,
    interruptible: I,
    env: E,
}

The implementation works well, but there is one last thing I am unsure about. The video also utilizes Zero-Sized Types (here those would be NonBlocking, Blocking, NoOutput, WithOutput etc.), but wraps them inside PhantomData. For my example, doing that as well would mean wrapping the blocking and output fields in PhantomData. Is there any benefit to doing this? From what I understand, PhantomData also represents ZST and acts as though it stores the underlying type (for static analysis by the compiler), but doesn't actually. Does this mean using it would be more memory efficient? Is the issue that I am not "telling" the compiler which actual types e.g. B can have? For instance, I know B can only be Blocking or NonBlocking, both ZST types, but I don't explicitly specify anywhere that those are the only accepted types, so maybe the compiler doesn't know and therefore does give that field a non-zero size at runtime.

1 Like

If your typestate markers are always declared as struct Typestate;, there's no intrinsic benefit to having a field of type PhantomData<TState> instead of just TState. In fact, holding typestate by value can be preferable, as then it's possible to use it more as a "strategy" pattern and permit strategies that may involve state.

On the other hand, some users of the typestate pattern prefer to declare typestate markers as enum Typestate {}. This way they're not just zero-sized, but they're actually uninhabited, meaning that an instance of the type is cannot exist, and it's more immediately obvious that the type is only intended meaningful as a type system marker, not as a value.

But what was that "intrinsic" disclaimer? One caveat is about trait implementations. PhantomData<Anything> is Copy and all of the other std derivable traits, but your typestate types aren't unless you derive them. But also, if you want to derive the traits on your marked type, the derived implementation will expect your marker type parameter to implement the trait anyway, even if it's only used as PhantomData.

The second is more advanced. If you want to transmute between typestates (e.g. required because it's behind any kind of indirection (e.g. Box) or just desired for potential perf benefits), it's imperative that your type has a guaranteed consistent layout across all typestates. This is simplest to achieve by defining a struct TyInner which does not have any typestate markers and make struct Ty a #[repr(transparent)] wrapper of TyInner with PhantomData of the typestate markers. For #[repr(transparent)] to be allowed, it is a requirement that all fields other than TyInner be 1-ZSTs, and PhantomData is the only way to ensure that for a generic parameter (i.e. the typestate marker(s)). This also permits you to more easily write helper functionality which is typestate agnostic on TyInner, as well as potentially make TyInner available to downstream that wants to bypass using typestate for one reason or another.

I personally generally find it preferable to provide a traditional builder with &mut self methods and a typestate using wrapper (with self methods) instead of only providing the latter, due to the former being easier to use and necessary in dynamic use cases, even if the latter is harder to misuse. It's simple enough to add typestate assurances around a dynamic builder, but it's not possible to remove them.

In the :sparkles::unicorn: future, the best way to achieve the typestate pattern will probably be with an enum State and a const STATE: State generic, leaving non-const type parameters for the potentially stateful strategy pattern. But the necessary support for that is still a good ways away from being stable. (Though you can imitate this on stable by using an integer as your typestate marker and translating to the enum manually.)

Aside: it's not particularly practical, but you can make a typestate compatible strategy by defining a number of different traits capturing the state transforms between associated types.

6 Likes

Thanks for the elaborate answer!
So what would you recommend in my case? Is there a benefit to switching some of the typestate markers (e.g. Blocking, NonBlocking) to the enum representation with PhantomData? You mentionned some downsides of this enum-version, and the upside that it is more obvious that the type is just a marker, but are there any performance differences? I would say I don't really need the capabilities of the strategy pattern, it's just about building a configuration once and using it later (with &self), for now at least. Also, this isn't part of some API in a library, it's just some internal wrapper that I use in a cli tool project.
So yeah, any recommendations for my specific case?