736-typestate-connection — Typestate Connection
Tutorial
The Problem
Network connection objects are the canonical example of runtime state misuse: calling send() before connect() or after close() is a logic error that only manifests at runtime. Production code adds defensive if !self.connected { return Err(...) } checks everywhere. The typestate pattern eliminates these checks: send and recv only exist on TcpConn<Connected>, so calling them on a disconnected connection is a compile error. Used in production in the tokio-serial and embedded-hal crates.
🎯 Learning Outcomes
Disconnected, Connecting, Connected, Closedsend and recv are only callable in the Connected stateResult from transition methods to handle IO errors without breaking the typestate invariantbytes_sent, bytes_recv) safely within the typed connectionResult: connect() returns Result<TcpConn<Connected>, _>Code Example
#![allow(clippy::all)]
/// 736: TCP Connection modelled as typestate
/// Send/recv only available on Connected; connect only on Disconnected.
use std::marker::PhantomData;
// ── State markers ─────────────────────────────────────────────────────────────
pub struct Disconnected;
pub struct Connecting;
pub struct Connected;
pub struct Closed;
// ── Connection ────────────────────────────────────────────────────────────────
pub struct TcpConn<State> {
host: String,
port: u16,
// In a real impl, this would hold a socket fd
bytes_sent: usize,
bytes_recv: usize,
_state: PhantomData<State>,
}
impl TcpConn<Disconnected> {
pub fn new(host: impl Into<String>, port: u16) -> Self {
TcpConn {
host: host.into(),
port,
bytes_sent: 0,
bytes_recv: 0,
_state: PhantomData,
}
}
/// Transition: Disconnected → Connected
pub fn connect(self) -> Result<TcpConn<Connected>, String> {
println!("Connecting to {}:{} ...", self.host, self.port);
// In reality: TcpStream::connect(...)
Ok(TcpConn {
host: self.host,
port: self.port,
bytes_sent: 0,
bytes_recv: 0,
_state: PhantomData,
})
}
}
impl TcpConn<Connected> {
/// Send data — only available when Connected.
pub fn send(mut self, data: &[u8]) -> Result<TcpConn<Connected>, String> {
println!("[{}:{}] → {} bytes", self.host, self.port, data.len());
self.bytes_sent += data.len();
Ok(self)
}
/// Receive data — only available when Connected.
pub fn recv(mut self) -> Result<(Vec<u8>, TcpConn<Connected>), String> {
let fake_data = b"HTTP/1.1 200 OK\r\n".to_vec();
println!("[{}:{}] ← {} bytes", self.host, self.port, fake_data.len());
self.bytes_recv += fake_data.len();
Ok((fake_data, self))
}
/// Transition: Connected → Closed
pub fn close(self) -> TcpConn<Closed> {
println!(
"Closing {}:{} (sent={}, recv={})",
self.host, self.port, self.bytes_sent, self.bytes_recv
);
TcpConn {
host: self.host,
port: self.port,
bytes_sent: self.bytes_sent,
bytes_recv: self.bytes_recv,
_state: PhantomData,
}
}
pub fn peer(&self) -> String {
format!("{}:{}", self.host, self.port)
}
}
impl TcpConn<Closed> {
pub fn bytes_sent(&self) -> usize {
self.bytes_sent
}
pub fn bytes_recv(&self) -> usize {
self.bytes_recv
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn connect_then_close() {
let conn = TcpConn::<Disconnected>::new("localhost", 8080);
let conn = conn.connect().unwrap();
let closed = conn.close();
assert_eq!(closed.bytes_sent(), 0);
assert_eq!(closed.bytes_recv(), 0);
}
#[test]
fn send_recv_accumulates_bytes() {
let conn = TcpConn::<Disconnected>::new("localhost", 8080)
.connect()
.unwrap();
let conn = conn.send(b"hello world").unwrap();
let (_data, conn) = conn.recv().unwrap();
let closed = conn.close();
assert_eq!(closed.bytes_sent(), 11);
assert!(closed.bytes_recv() > 0);
}
#[test]
fn peer_returns_host_and_port() {
let conn = TcpConn::<Disconnected>::new("example.com", 443)
.connect()
.unwrap();
assert_eq!(conn.peer(), "example.com:443");
conn.close();
}
}Key Differences
self prevents double-use; OCaml's modules must use abstract types and hide constructors to achieve the same effect.Result<TcpConn<Connected>, E> combines typestate with error propagation naturally; OCaml uses result type or exceptions similarly.Lwt_unix returns promise-wrapped connection types; Rust's tokio::net::TcpStream uses async fn with the same typestate ideas.OCaml Approach
OCaml models connection typestate using abstract types in separate modules. A Disconnected.t and Connected.t are distinct types exposed through module signatures. The connect : Disconnected.t -> (Connected.t, exn) result function enforces the transition. OCaml's Lwt and Async add monadic sequencing, making it natural to chain connect >>= send >>= recv >>= close.
Full Source
#![allow(clippy::all)]
/// 736: TCP Connection modelled as typestate
/// Send/recv only available on Connected; connect only on Disconnected.
use std::marker::PhantomData;
// ── State markers ─────────────────────────────────────────────────────────────
pub struct Disconnected;
pub struct Connecting;
pub struct Connected;
pub struct Closed;
// ── Connection ────────────────────────────────────────────────────────────────
pub struct TcpConn<State> {
host: String,
port: u16,
// In a real impl, this would hold a socket fd
bytes_sent: usize,
bytes_recv: usize,
_state: PhantomData<State>,
}
impl TcpConn<Disconnected> {
pub fn new(host: impl Into<String>, port: u16) -> Self {
TcpConn {
host: host.into(),
port,
bytes_sent: 0,
bytes_recv: 0,
_state: PhantomData,
}
}
/// Transition: Disconnected → Connected
pub fn connect(self) -> Result<TcpConn<Connected>, String> {
println!("Connecting to {}:{} ...", self.host, self.port);
// In reality: TcpStream::connect(...)
Ok(TcpConn {
host: self.host,
port: self.port,
bytes_sent: 0,
bytes_recv: 0,
_state: PhantomData,
})
}
}
impl TcpConn<Connected> {
/// Send data — only available when Connected.
pub fn send(mut self, data: &[u8]) -> Result<TcpConn<Connected>, String> {
println!("[{}:{}] → {} bytes", self.host, self.port, data.len());
self.bytes_sent += data.len();
Ok(self)
}
/// Receive data — only available when Connected.
pub fn recv(mut self) -> Result<(Vec<u8>, TcpConn<Connected>), String> {
let fake_data = b"HTTP/1.1 200 OK\r\n".to_vec();
println!("[{}:{}] ← {} bytes", self.host, self.port, fake_data.len());
self.bytes_recv += fake_data.len();
Ok((fake_data, self))
}
/// Transition: Connected → Closed
pub fn close(self) -> TcpConn<Closed> {
println!(
"Closing {}:{} (sent={}, recv={})",
self.host, self.port, self.bytes_sent, self.bytes_recv
);
TcpConn {
host: self.host,
port: self.port,
bytes_sent: self.bytes_sent,
bytes_recv: self.bytes_recv,
_state: PhantomData,
}
}
pub fn peer(&self) -> String {
format!("{}:{}", self.host, self.port)
}
}
impl TcpConn<Closed> {
pub fn bytes_sent(&self) -> usize {
self.bytes_sent
}
pub fn bytes_recv(&self) -> usize {
self.bytes_recv
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn connect_then_close() {
let conn = TcpConn::<Disconnected>::new("localhost", 8080);
let conn = conn.connect().unwrap();
let closed = conn.close();
assert_eq!(closed.bytes_sent(), 0);
assert_eq!(closed.bytes_recv(), 0);
}
#[test]
fn send_recv_accumulates_bytes() {
let conn = TcpConn::<Disconnected>::new("localhost", 8080)
.connect()
.unwrap();
let conn = conn.send(b"hello world").unwrap();
let (_data, conn) = conn.recv().unwrap();
let closed = conn.close();
assert_eq!(closed.bytes_sent(), 11);
assert!(closed.bytes_recv() > 0);
}
#[test]
fn peer_returns_host_and_port() {
let conn = TcpConn::<Disconnected>::new("example.com", 443)
.connect()
.unwrap();
assert_eq!(conn.peer(), "example.com:443");
conn.close();
}
}#[cfg(test)]
mod tests {
use super::*;
#[test]
fn connect_then_close() {
let conn = TcpConn::<Disconnected>::new("localhost", 8080);
let conn = conn.connect().unwrap();
let closed = conn.close();
assert_eq!(closed.bytes_sent(), 0);
assert_eq!(closed.bytes_recv(), 0);
}
#[test]
fn send_recv_accumulates_bytes() {
let conn = TcpConn::<Disconnected>::new("localhost", 8080)
.connect()
.unwrap();
let conn = conn.send(b"hello world").unwrap();
let (_data, conn) = conn.recv().unwrap();
let closed = conn.close();
assert_eq!(closed.bytes_sent(), 11);
assert!(closed.bytes_recv() > 0);
}
#[test]
fn peer_returns_host_and_port() {
let conn = TcpConn::<Disconnected>::new("example.com", 443)
.connect()
.unwrap();
assert_eq!(conn.peer(), "example.com:443");
conn.close();
}
}
Exercises
Reconnecting state between Closed and Connected with a reconnect() method that retries up to N times.TlsConn<State> that wraps TcpConn<Connected> and adds a tls_handshake() transition to TlsConn<Secured> before allowing encrypted send/recv.Pipeline<C: Connected> that accepts any Connected connection type and sends a sequence of protocol messages, returning accumulated statistics.