Table of contents
In my last article, I explored how you can use
enum using Rust to create unambiguous JSON representations. In this one, I’ll look at another interesting feature that Rust’s types and traits system gives us.
For example, let’s say you are building a system that represents an order flow. It’s an example I love to use because we can easily image what are the most important steps in the process. Once an ordered is placed, someone needs to package the item in one or multiple boxes, then ships it to the end customer. We basically have four main states:
Pending: the order has been created, but not yet submitted for packaging.
Packaging: the order is being packaged in a warehouse.
InDelivery: the order is being delivered to the customer.
Completed: the order has been processed.
There can be a lot of other steps in between in a real life scenario, such as payment verification, or a packaged order waiting to be picked up, or cancelled because the items are not in stock. We’ll just look at those four states for the sake of simplicity here.
We could create an
enum with a variant for each state, then have that as a field in our order struct. We can also use the fact that Rust supports data in variants to add information, such as the tracking ID when an order is being delivered.
/// Enum representing all the possible states for an order
To transition an order from one state into another, we could implement functions that will take care of changing the state. For example, a
ship function would change it into the
However, there is a catch: we cannot transition from any state to any state. There is a logical order that we must follow. We can’t ship an order that completed, or submit a shipped order for packaging. For this, we can add runtime checks:
While this will catch issues and correctly prevent us from performing invalid transitions, it does it at runtime. That means that the code is already in production or in a testing environment.
This is where typestate analysis comes in. The idea of typestate is to embed state information into the type itself, and restrict which operations we can perform on a given type-state combination. Instead of adding runtime checks, we can now perform those when we are compiling our application.
Instead of using an
enum, we can use a
structs implementing it.
/// Trait representing an order state // Struct that will implement that trait ; ; ; // Trait implementations /// New order struct that uses the trait instead
We can now restrict implementations to only
Orders that use a specific implementation of our
OrderState trait. For example, we can implement the
ship function only if the Order is in the
Packaging state. The benefit of that approach is that the code won’t compile if we try to ship a completed or newly created order. We have removed the ability to create potential bugs that wouldn’t match our order flow, without adding runtime checks.
If you’re interested to see a working implementation, you can find this repository on GitHub that models the order flow discussed in this article. You can also look at the static guarantee section of the Rust embedded book, which explains how typestate is used to prevent sending data to an input pin, or reading from an output pin on hardware devices.
As a closing note, I’d like to point out that this approach is not without drawbacks.
The first one is that it adds a lot of complexity and repetition depending on how complex your state machine is. If you can accept 3 states out of 7, it’s easy to do with a
match pattern in Rust. With typestate, you might need to duplicate implementation for each concrete implementation.
Furthermore, depending on how you implement this with the rest of your application, you might end up either with multiple copies of function (one for each trait variant) due to static dispatch, or with recreating the same runtime logic with dynamic dispatch. See this section of the Rust book to understand more about how Rust dispatches traits.