Skip to content

Commit 50244f8

Browse files
committed
add typestate pattern chapter
1 parent 059b44b commit 50244f8

File tree

3 files changed

+227
-0
lines changed

3 files changed

+227
-0
lines changed

src/SUMMARY.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,8 @@
437437
- [Semantic Confusion](idiomatic/leveraging-the-type-system/newtype-pattern/semantic-confusion.md)
438438
- [Parse, Don't Validate](idiomatic/leveraging-the-type-system/newtype-pattern/parse-don-t-validate.md)
439439
- [Is It Encapsulated?](idiomatic/leveraging-the-type-system/newtype-pattern/is-it-encapsulated.md)
440+
- [Typestate Pattern](idiomatic/leveraging-the-type-system/typestate-pattern.md)
441+
- [Typestate Pattern with Generics](idiomatic/leveraging-the-type-system/typestate-pattern/typestate-generics.md)
440442

441443
---
442444

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
---
2+
minutes: 15
3+
---
4+
5+
## Typestate Pattern
6+
7+
The typestate pattern uses Rust’s type system to make **invalid states
8+
unrepresentable**.
9+
10+
```rust
11+
# use std::fmt::Write;
12+
#[derive(Default)]
13+
struct Serializer { output: String }
14+
struct SerializeStruct { ser: Serializer }
15+
16+
impl Serializer {
17+
fn serialize_struct(mut self, name: &str) -> SerializeStruct {
18+
let _ = writeln!(&mut self.output, "{name} {{");
19+
SerializeStruct { ser: self }
20+
}
21+
}
22+
23+
impl SerializeStruct {
24+
fn serialize_field(mut self, key: &str, value: &str) -> Self {
25+
let _ = writeln!(&mut self.ser.output, " {key}={value};");
26+
self
27+
}
28+
29+
fn finish_struct(mut self) -> Serializer {
30+
self.ser.output.push_str("}\n");
31+
self.ser
32+
}
33+
}
34+
35+
let ser = Serializer::default()
36+
.serialize_struct("User")
37+
.serialize_field("id", "42")
38+
.serialize_field("name", "Alice")
39+
.finish_struct();
40+
println!("{}", ser.output);
41+
```
42+
43+
<details>
44+
45+
- This example is inspired by
46+
[Serde's `Serializer` trait](https://docs.rs/serde/latest/serde/ser/trait.Serializer.html).
47+
For a deeper explanation of how Serde models serialization as a state machine,
48+
see <https://serde.rs/impl-serializer.html>.
49+
50+
- The typestate pattern allows us to model state machines using Rust’s type
51+
system. In this case, the state machine is a simple serializer.
52+
53+
- The key idea is that each state in the process, starting a struct, writing
54+
fields, and finishing, is represented by a different type. Transitions between
55+
states happen by consuming one value and producing another.
56+
57+
- In the example above:
58+
59+
- Once we begin serializing a struct, the `Serializer` is moved into the
60+
`SerializeStruct` state. At that point, we no longer have access to the
61+
original `Serializer`.
62+
63+
- While in the `SerializeStruct` state, we can only call methods related to
64+
writing fields. We cannot use the same instance to serialize a tuple, list,
65+
or primitive. Those constructors simply do not exist here.
66+
67+
- Only after calling `finish_struct` do we get the `Serializer` back. At that
68+
point, we can inspect the output or start a new serialization session.
69+
70+
- If we forget to call `finish_struct` and drop the `SerializeStruct` instead,
71+
the original `Serializer` is lost. This ensures that incomplete or invalid
72+
output can never be observed.
73+
74+
- By contrast, if all methods were defined on `Serializer` itself, nothing would
75+
prevent users from mixing serialization modes or leaving a struct unfinished.
76+
77+
- This pattern avoids such misuse by making it **impossible to represent invalid
78+
transitions**.
79+
80+
- One downside of typestate modeling is potential code duplication between
81+
states. In the next section, we will see how to use **generics** to reduce
82+
duplication while preserving correctness.
83+
84+
</details>
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
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

Comments
 (0)