|
| 1 | +## Typestate Pattern with Generics |
| 2 | + |
| 3 | +Generics can be used with the typestate pattern to reduce duplication and allow |
| 4 | +shared logic across state variants, while still encoding state transitions in |
| 5 | +the type system. |
| 6 | + |
| 7 | +```rust |
| 8 | +# fn main() -> std::io::Result<()> { |
| 9 | +#[non_exhaustive] |
| 10 | +struct Insecure; |
| 11 | +struct Secure { |
| 12 | + client_cert: Option<Vec<u8>>, |
| 13 | +} |
| 14 | + |
| 15 | +trait Transport { |
| 16 | + /* ... */ |
| 17 | +} |
| 18 | +impl Transport for Insecure { |
| 19 | + /* ... */ |
| 20 | +} |
| 21 | +impl Transport for Secure { |
| 22 | + /* ... */ |
| 23 | +} |
| 24 | + |
| 25 | +#[non_exhaustive] |
| 26 | +struct WantsTransport; |
| 27 | +struct Ready<T> { |
| 28 | + transport: T, |
| 29 | +} |
| 30 | + |
| 31 | +struct ConnectionBuilder<T> { |
| 32 | + host: String, |
| 33 | + timeout: Option<u64>, |
| 34 | + stage: T, |
| 35 | +} |
| 36 | + |
| 37 | +struct Connection {/* ... */} |
| 38 | + |
| 39 | +impl Connection { |
| 40 | + fn new(host: &str) -> ConnectionBuilder<WantsTransport> { |
| 41 | + ConnectionBuilder { |
| 42 | + host: host.to_owned(), |
| 43 | + timeout: None, |
| 44 | + stage: WantsTransport, |
| 45 | + } |
| 46 | + } |
| 47 | +} |
| 48 | + |
| 49 | +impl<T> ConnectionBuilder<T> { |
| 50 | + fn timeout(mut self, secs: u64) -> Self { |
| 51 | + self.timeout = Some(secs); |
| 52 | + self |
| 53 | + } |
| 54 | +} |
| 55 | + |
| 56 | +impl ConnectionBuilder<WantsTransport> { |
| 57 | + fn insecure(self) -> ConnectionBuilder<Ready<Insecure>> { |
| 58 | + ConnectionBuilder { |
| 59 | + host: self.host, |
| 60 | + timeout: self.timeout, |
| 61 | + stage: Ready { transport: Insecure }, |
| 62 | + } |
| 63 | + } |
| 64 | + |
| 65 | + fn secure(self) -> ConnectionBuilder<Ready<Secure>> { |
| 66 | + ConnectionBuilder { |
| 67 | + host: self.host, |
| 68 | + timeout: self.timeout, |
| 69 | + stage: Ready { transport: Secure { client_cert: None } }, |
| 70 | + } |
| 71 | + } |
| 72 | +} |
| 73 | + |
| 74 | +impl ConnectionBuilder<Ready<Secure>> { |
| 75 | + fn client_certificate(mut self, raw: Vec<u8>) -> Self { |
| 76 | + self.stage.transport.client_cert = Some(raw); |
| 77 | + self |
| 78 | + } |
| 79 | +} |
| 80 | + |
| 81 | +impl<T: Transport> ConnectionBuilder<Ready<T>> { |
| 82 | + fn connect(self) -> std::io::Result<Connection> { |
| 83 | + // ... use valid state to establish the configured connection |
| 84 | + Ok(Connection {}) |
| 85 | + } |
| 86 | +} |
| 87 | + |
| 88 | +let _conn = Connection::new("db.local") |
| 89 | + .secure() |
| 90 | + .client_certificate(vec![1, 2, 3]) |
| 91 | + .timeout(10) |
| 92 | + .connect()?; |
| 93 | +Ok(()) |
| 94 | +# } |
| 95 | +``` |
| 96 | + |
| 97 | +<details> |
| 98 | + |
| 99 | +- This example extends the typestate pattern using **generic parameters** to |
| 100 | + avoid duplication of common logic. |
| 101 | + |
| 102 | +- We use a generic type `T` to represent the current stage of the builder, and |
| 103 | + share fields like `host` and `timeout` across all stages. |
| 104 | + |
| 105 | +- The transport phase uses `insecure()` and `secure()` to transition from |
| 106 | + `WantsTransport` into `Ready<T>`, where `T` is a type that implements the |
| 107 | + `Transport` trait. |
| 108 | + |
| 109 | +- Only once the connection is in a `Ready<T>` state, we can call `.connect()`, |
| 110 | + guaranteed at compile time. |
| 111 | + |
| 112 | +- Using generics allows us to avoid writing separate `BuilderForSecure`, |
| 113 | + `BuilderForInsecure`, etc. structs. |
| 114 | + |
| 115 | + Shared behavior, like `.timeout(...)`, can be implemented once and reused |
| 116 | + across all states. |
| 117 | + |
| 118 | +- This same design appears |
| 119 | + [in real-world libraries like **Rustls**](https://docs.rs/rustls/latest/rustls/struct.ConfigBuilder.html), |
| 120 | + where the `ConfigBuilder` uses typestate and generics to guide users through a |
| 121 | + safe, ordered configuration flow. |
| 122 | + |
| 123 | + It enforces at compile time that users must choose protocol versions, a |
| 124 | + certificate verifier, and client certificate options, in the correct sequence, |
| 125 | + before building a config. |
| 126 | + |
| 127 | +- **Downsides** of this approach include: |
| 128 | + - The documentation of the various builder types can become difficult to |
| 129 | + follow, since their names are generated by generics and internal structs |
| 130 | + like `Ready<T>`. |
| 131 | + - Error messages from the compiler may become more opaque, especially if a |
| 132 | + trait bound is not satisfied or a state transition is incomplete. |
| 133 | + |
| 134 | + The error messages might also be hard to follow due to the complexity as a |
| 135 | + result of the nested generics types. |
| 136 | + |
| 137 | +- Still, in return for this complexity, you get compile-time enforcement of |
| 138 | + valid configuration, clear builder sequencing, and no possibility of |
| 139 | + forgetting a required step or misusing the API at runtime. |
| 140 | + |
| 141 | +</details> |
0 commit comments