PhantomData for API Safety
Functional Programming
Tutorial
The Problem
Database connections, file handles, and network sockets have a lifecycle: they must be opened before use and closed after use. Calling query methods on a closed connection causes runtime errors. PhantomData-based typestate encodes the connection state in the type: Connection<Closed> and Connection<Open> are different types, with query methods available only on Connection<Open>. Opening a closed connection returns Connection<Open>; closing an open connection returns Connection<Closed>.
🎯 Learning Outcomes
PhantomData<State> adds no runtime overheadCode Example
struct Connection<State> { host: String, _state: PhantomData<State> }
impl Connection<Closed> {
fn open(self) -> Connection<Open> { /* ... */ }
}
impl Connection<Open> {
fn query(&self, sql: &str) -> String { /* ... */ }
fn close(self) -> Connection<Closed> { /* ... */ }
}Key Differences
open(self) consumes the closed connection — the old binding cannot be used; OCaml retains the old value in scope.open is a compile error ("value used after move"); OCaml: same value remains accessible.Drop to auto-close on drop; OCaml uses finalizers (unreliable for deterministic resource cleanup).OCaml Approach
OCaml's phantom type approach:
type closed = Closed
type open_ = Open
type 'state connection = { host: string }
let open_conn (c: closed connection) : open_ connection = c
let close_conn (c: open_ connection) : closed connection = c
let query (c: open_ connection) : string = "result"
This works but does not prevent using the old closed connection after calling open_conn — OCaml's GC keeps the old value alive, so the programmer can accidentally use it. Rust's move semantics make this impossible.
Full Source
//! 180: PhantomData for API Safety.
//!
//! The typestate pattern encodes protocol state in the type system so invalid
//! transitions (e.g. querying a closed connection) fail at compile time.
//! This module mirrors the three OCaml approaches: a direct typestate
//! `Connection<S>`, a module-scoped `safe_conn` variant, and a functor-style
//! state-machine `GenericConnection<S>`.
use std::marker::PhantomData;
/// Marker type for a closed connection state.
pub struct Closed;
/// Marker type for an open connection state.
pub struct Open;
/// A connection whose state is encoded in the type parameter `S`.
///
/// Methods are gated by state: only `Connection<Closed>` can `open`, only
/// `Connection<Open>` can `query` or `close`. The `host` accessor is available
/// in any state.
pub struct Connection<S> {
host: String,
handle: Option<u32>,
_state: PhantomData<S>,
}
impl<S> Connection<S> {
/// Returns the host string, regardless of state.
pub fn host(&self) -> &str {
&self.host
}
}
impl Connection<Closed> {
/// Creates a new closed connection for the given host.
pub fn new(host: impl Into<String>) -> Self {
Connection {
host: host.into(),
handle: None,
_state: PhantomData,
}
}
/// Opens the connection, consuming the closed handle and returning an open one.
pub fn open(self) -> Connection<Open> {
Connection {
host: self.host,
handle: Some(42),
_state: PhantomData,
}
}
}
impl Connection<Open> {
/// Runs a SQL query and returns the formatted result string.
pub fn query(&self, sql: &str) -> String {
let _handle = self.handle.expect("open connection must have a handle");
format!("result({}): {}", self.host, sql)
}
/// Closes the connection, consuming the open handle and returning a closed one.
pub fn close(self) -> Connection<Closed> {
Connection {
host: self.host,
handle: None,
_state: PhantomData,
}
}
}
/// Module-scoped variant: the inner struct and state markers stay private,
/// exposing only the transition API. This mirrors the OCaml `SafeConn` module
/// with an abstract `'a conn` type.
pub mod safe_conn {
use std::marker::PhantomData;
/// Marker for the opened state.
pub struct Opened;
/// Marker for the closed state.
pub struct ClosedS;
/// Connection with private fields; state is carried in `S`.
pub struct Conn<S> {
host: String,
#[allow(dead_code)]
handle: Option<u32>,
_state: PhantomData<S>,
}
impl<S> Conn<S> {
/// Host accessor available in every state.
pub fn host(&self) -> &str {
&self.host
}
}
impl Conn<ClosedS> {
/// Creates a closed connection for the given host.
pub fn create(host: impl Into<String>) -> Self {
Conn {
host: host.into(),
handle: None,
_state: PhantomData,
}
}
/// Opens the connection.
pub fn open_conn(self) -> Conn<Opened> {
Conn {
host: self.host,
handle: Some(1),
_state: PhantomData,
}
}
}
impl Conn<Opened> {
/// Runs a query against the opened connection.
pub fn query(&self, sql: &str) -> String {
format!("result({}): {}", self.host, sql)
}
/// Closes the connection.
pub fn close(self) -> Conn<ClosedS> {
Conn {
host: self.host,
handle: None,
_state: PhantomData,
}
}
}
}
/// Trait bound used by the functor-style state machine.
///
/// Callers may implement this for their own state markers.
pub trait ConnState {}
/// Functor-style marker implementing [`ConnState`] for the closed state.
pub struct ClosedFS;
impl ConnState for ClosedFS {}
/// Functor-style marker implementing [`ConnState`] for the open state.
pub struct OpenFS;
impl ConnState for OpenFS {}
/// Generic connection parameterised over any [`ConnState`].
pub struct GenericConnection<S: ConnState> {
host: String,
port: u16,
_state: PhantomData<S>,
}
impl<S: ConnState> GenericConnection<S> {
/// Host accessor, available in every state.
pub fn host(&self) -> &str {
&self.host
}
/// Port accessor, available in every state.
pub fn port(&self) -> u16 {
self.port
}
}
impl GenericConnection<ClosedFS> {
/// Constructs a new closed generic connection.
pub fn create(host: impl Into<String>, port: u16) -> Self {
GenericConnection {
host: host.into(),
port,
_state: PhantomData,
}
}
/// Transitions the connection from closed to open.
pub fn open(self) -> GenericConnection<OpenFS> {
GenericConnection {
host: self.host,
port: self.port,
_state: PhantomData,
}
}
}
impl GenericConnection<OpenFS> {
/// Runs a query on the open generic connection.
pub fn query(&self, sql: &str) -> String {
format!("{}:{} => {}", self.host, self.port, sql)
}
/// Transitions the connection from open back to closed.
pub fn close(self) -> GenericConnection<ClosedFS> {
GenericConnection {
host: self.host,
port: self.port,
_state: PhantomData,
}
}
}
#[cfg(test)]
mod tests {
use super::safe_conn::{ClosedS, Conn, Opened};
use super::*;
#[test]
fn connection_open_query_close_roundtrip() {
let c: Connection<Closed> = Connection::new("localhost");
assert_eq!(c.host(), "localhost");
let o = c.open();
let result = o.query("SELECT 1");
assert_eq!(result, "result(localhost): SELECT 1");
let c2 = o.close();
assert_eq!(c2.host(), "localhost");
}
#[test]
fn connection_host_accessor_works_in_either_state() {
let closed = Connection::<Closed>::new("db.example.com");
assert_eq!(closed.host(), "db.example.com");
let open = closed.open();
assert_eq!(open.host(), "db.example.com");
}
#[test]
fn connection_allows_multiple_queries() {
let o = Connection::new("srv").open();
assert_eq!(o.query("A"), "result(srv): A");
assert_eq!(o.query("B"), "result(srv): B");
}
#[test]
fn connection_can_be_reopened_after_close() {
let c = Connection::new("h").open().close();
let o = c.open();
assert_eq!(o.query("ping"), "result(h): ping");
}
#[test]
fn safe_conn_module_roundtrip() {
let c: Conn<ClosedS> = Conn::create("db.example.com");
let o: Conn<Opened> = c.open_conn();
let r = o.query("SELECT * FROM users");
assert!(!r.is_empty());
assert_eq!(r, "result(db.example.com): SELECT * FROM users");
let c2 = o.close();
assert_eq!(c2.host(), "db.example.com");
}
#[test]
fn generic_connection_basic_flow() {
let c = GenericConnection::<ClosedFS>::create("example.com", 5432);
assert_eq!(c.host(), "example.com");
assert_eq!(c.port(), 5432);
let o = c.open();
let r = o.query("SELECT * FROM users");
assert_eq!(r, "example.com:5432 => SELECT * FROM users");
let c2 = o.close();
assert_eq!(c2.host(), "example.com");
assert_eq!(c2.port(), 5432);
}
// The following, if uncommented, would fail to compile — proof that the
// phantom-state machinery rejects bad transitions at the type level:
//
// let c = Connection::<Closed>::new("x");
// c.query("nope"); // no method `query` on Connection<Closed>
//
// let o = Connection::new("x").open();
// o.open(); // no method `open` on Connection<Open>
}
✓ Tests
Rust test suite
#[cfg(test)]
mod tests {
use super::safe_conn::{ClosedS, Conn, Opened};
use super::*;
#[test]
fn connection_open_query_close_roundtrip() {
let c: Connection<Closed> = Connection::new("localhost");
assert_eq!(c.host(), "localhost");
let o = c.open();
let result = o.query("SELECT 1");
assert_eq!(result, "result(localhost): SELECT 1");
let c2 = o.close();
assert_eq!(c2.host(), "localhost");
}
#[test]
fn connection_host_accessor_works_in_either_state() {
let closed = Connection::<Closed>::new("db.example.com");
assert_eq!(closed.host(), "db.example.com");
let open = closed.open();
assert_eq!(open.host(), "db.example.com");
}
#[test]
fn connection_allows_multiple_queries() {
let o = Connection::new("srv").open();
assert_eq!(o.query("A"), "result(srv): A");
assert_eq!(o.query("B"), "result(srv): B");
}
#[test]
fn connection_can_be_reopened_after_close() {
let c = Connection::new("h").open().close();
let o = c.open();
assert_eq!(o.query("ping"), "result(h): ping");
}
#[test]
fn safe_conn_module_roundtrip() {
let c: Conn<ClosedS> = Conn::create("db.example.com");
let o: Conn<Opened> = c.open_conn();
let r = o.query("SELECT * FROM users");
assert!(!r.is_empty());
assert_eq!(r, "result(db.example.com): SELECT * FROM users");
let c2 = o.close();
assert_eq!(c2.host(), "db.example.com");
}
#[test]
fn generic_connection_basic_flow() {
let c = GenericConnection::<ClosedFS>::create("example.com", 5432);
assert_eq!(c.host(), "example.com");
assert_eq!(c.port(), 5432);
let o = c.open();
let r = o.query("SELECT * FROM users");
assert_eq!(r, "example.com:5432 => SELECT * FROM users");
let c2 = o.close();
assert_eq!(c2.host(), "example.com");
assert_eq!(c2.port(), 5432);
}
// The following, if uncommented, would fail to compile — proof that the
// phantom-state machinery rejects bad transitions at the type level:
//
// let c = Connection::<Closed>::new("x");
// c.query("nope"); // no method `query` on Connection<Closed>
//
// let o = Connection::new("x").open();
// o.open(); // no method `open` on Connection<Open>
}
Deep Comparison
Comparison: Example 180 — PhantomData for API Safety
Type-State Connection
OCaml
type _ connection =
| Closed : string -> closed_state connection
| Open : string * int -> open_state connection
let connect (Closed host) : open_state connection = Open (host, 42)
let query (Open (host, _)) sql = "result: " ^ sql
let close (Open (host, _)) : closed_state connection = Closed host
Rust
struct Connection<State> { host: String, _state: PhantomData<State> }
impl Connection<Closed> {
fn open(self) -> Connection<Open> { /* ... */ }
}
impl Connection<Open> {
fn query(&self, sql: &str) -> String { /* ... */ }
fn close(self) -> Connection<Closed> { /* ... */ }
}
Abstract Module vs Trait
OCaml
module SafeConn : sig
type 'a conn
type opened
type closed
val open_conn : closed conn -> opened conn
val query : opened conn -> string -> string
val close : opened conn -> closed conn
end
Rust
// No need for module abstraction — PhantomData + separate impls
// achieves the same: methods only exist on the right state type
impl Connection<Open> {
fn query(&self, sql: &str) -> String { /* ... */ }
}
// Connection<Closed> simply has no query method
Exercises
execute(&mut self, sql: &str) -> Result<(), String> method on Connection<Open> that simulates query execution.Connection<Open> → Connection<InTransaction> → Connection<Open> transitions with begin_transaction, commit, and rollback methods.Drop: auto-close the connection when Connection<Open> is dropped, logging a warning.