How to enforce that a function actually returns the end state from a given typestate builder?

Let's assume we write a library that provides a builder using the typestate pattern. Let's take the following code as an example:

mod lib {
    pub struct Start(pub u8);
    pub struct Step(u8);
    pub struct End(u8);
    
    impl Start { pub fn next(self) -> Step {Step(self.0)} }
    impl Step { pub fn next(self) -> End {End(self.0)} }
}

Note that because only the Start constructor is public End can only be obtained by stepping through the states. My question now is if we have a function that takes the start state and returns the end state, how can we enforce that the End actually comes from the given Start and not from another builder?

// should be valid
fn build1(start: Start) -> End {
    start.next().next()
}
// should be invalid
fn build2(start: Start) -> End {
    Start(0).next().next()
}

Apparently this can be enforced by doing some lifetime / variance acrobatics:

Since I am not well-versed in such acrobatics I am wondering a couple of things:

  • How could such tricks be applied to achieve the above with a typestate builder?

  • What's the difference between struct A<'a>(PhantomData<fn(&'a ()) -> &'a ()>) and struct B<'a>(PhantomData<&'a mut &'a ()>)? (I have to admit that I don't really understand the implications of either)

  • What are the drawbacks to using something like this in a library? Can such tricks be assumed to work reliably with future versions of Rust?

You would need to annotate the objects with a lifetime and ensure that the lifetime is invariant.

Nothing. It's just two different ways to make the lifetime invariant.

Putting lifetimes on stuff can lead to various challenges, e.g. you can't use APIs that require 'static. It is guaranteed to work in future versions of Rust.

1 Like

Thank you very much for your quick reply! The following code seems to do the trick. Though I still have some questions:

  • I guess we need the PhantomData invariant trick in every state, so that the invariance holds from Start to End?
  • does it make sense to set the lifetime to 'static in the Start constructor?
  • am I right to assume that the lifetime used for the invariance shouldn't be used for anything else (e.g. other struct fields that are passed along)?
mod lib {
    use core::marker::PhantomData;

    pub struct Start<'a>(u8, PhantomData<&'a mut &'a ()>);
    pub struct Step<'a>(u8, PhantomData<&'a mut &'a ()>);
    pub struct End<'a>(u8, PhantomData<&'a mut &'a ()>);
    
    impl<'a> Start<'a> { pub fn next(self) -> Step<'a> {Step(self.0, self.1)} }
    impl<'a> Step<'a> { pub fn next(self) -> End<'a> {End(self.0, self.1)} }
    
    impl Start<'_> { pub fn new(num: u8) -> Start<'static> { Start(num, PhantomData) } }
}

use crate::lib::{Start, End};

// should be valid
fn build1(start: Start) -> End {
    start.next().next()
}
// should be invalid
fn build2(start: Start) -> End {
    Start::new(0).next().next()
}

No you can't set the lifetime to static because the goal is to avoid mixing up elements with two different lifetimes. You can't do that if they all have the same lifetime.

The compact_arena crate appears to define a tagged! macro for creating new unique lifetimes.

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.