How to use the typestate pattern for event processing? Is it a good idea?

I've read recently about the typestate pattern and I think that it can be very useful for writing the foundation of my application.

As I've included in the example, it's very interesting for writing builders that avoid errors at compile time.

But my problem arises when I try to use it in dynamic environment, where I can't control the order of the operations. What can I do in these cases?

struct Product {
    product_id: u64,
}
struct Invoice {
    product_id: u64,
    invoice_address: String,
}
struct Shipping {
    product_id: u64,
    invoice_address: String,
    shipping_address: String,
}

trait Step {}

impl Step for Product {}
impl Step for Invoice {}
impl Step for Shipping {}

struct Process<T: Step + ?Sized> {
    step: Box<T>,
}

impl Process<Product> {
    pub fn select_product(product_id: u64) -> Process<Product> {
        Process {
            step: Box::new(Product { product_id }),
        }
    }

    pub fn set_invoice_address(&self, invoice_address: String) -> Process<Invoice> {
        Process {
            step: Box::new(Invoice {
                product_id: self.step.product_id,
                invoice_address,
            }),
        }
    }
}
impl Process<Invoice> {
    pub fn set_shipping_address(&self, shipping_address: String) -> Process<Shipping> {
        Process {
            step: Box::new(Shipping {
                product_id: self.step.product_id,
                invoice_address: self.step.invoice_address.clone(),
                shipping_address,
            }),
        }
    }
}
impl Process<Shipping> {
    pub fn pay(&self) {
        println!("PAY");
    }
}

// Chaining works perfectly
fn chaining(product_id: u64, invoice_address: String, shipping_address: String) {
    Process::<Product>::select_product(product_id)
        .set_invoice_address(invoice_address)
        .set_shipping_address(shipping_address)
        .pay();
}

// State receives messages/events to process each step and store in memory the current state of the process
struct State {}

impl State {
    pub fn fill_product(&mut self, product_id: u64) {
        unimplemented!()
    }

    pub fn fill_invoice(&mut self, invoice_address: String) {
        unimplemented!()
    }
    
    pub fn reset_invoice(&mut self) {
        unimplemented!()
    }
    
    pub fn fill_shipping(&mut self, shipping_address: String) {
        unimplemented!()
    }

    pub fn reset_shipping(&mut self) {
        unimplemented!()
    }

    pub fn pay(&mut self) {
        unimplemented!()
    }
}

fn main() {
    let product_id = 10;
    let invoice_address = "foo".to_string();
    let shipping_address = "bar".to_string();

    chaining(product_id, invoice_address, shipping_address);
}

(Playground)

It can only be used at compile-time, for things that are hardcoded in the source code of the program.

If things change at run time, you have to use runtime constructs like enum.

1 Like

Thanks @kornel for your response.

Yes, enum is the obvious option, but I don't know how to combine both. I mean, I'd like to keep using these implementations everywhere in the application, so, if it is possible the code could be checked by the compiler in some parts, although other parts would be managed at run time manually. What would be the most rusty way to do it?

I suppose you could have Process<RunTimeState> too, and make an implementation for it that matches on the state each time.

Or flip it inside out and have:

enum RunTimeProcess {
   Product(Process<Product>), Invoice(Process<Invoice>), etc.
}

and then require wrapping/unwrapping types on each use.

But I'd advise against trying to have both. Typestate is a neat trick, but if you want both compile-time and run-time you'll end up implementing everything almost twice. It's more pragmatic to implement the run-time version only and ensure it's correct with unit tests.

1 Like

I'm going to follow your advise, @kornel: while I was editing my previous reply I started to reframe my needs and I have realised that I could use something simpler:

  • I would use one struct Product, Shipping, etc. with each data
  • These struct would be wrapped in a xxxStep for using them in the typestate.
  • I will create also an enum with one variant per struct.

Something like:

struct Product { data: ... }

struct ProcessStep(Process);
impl Step for ProcessStep {}

struct Process<T: Step + ?Sized> {
    step: Box<T>,
}

impl Process<ProductStep> {
    pub fn select_product(product_id: u64) -> Process<ProductStep> {
        Process {
            step: Box::new(ProductStep(Product { product_id })),
        }
    }
}

enum ProcessState {
  Product(Product),
  Invoice(Invoice),
  // etc.
}

So, this way, I could reuse the data part.

Do you have any suggestion about how to proxy the methods from wrapper (ProductStep) to the original data structure (Product) which don't involve writing a lot of boilerplate? Maybe macros?