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
#![allow(clippy::all)]
// Example 180: PhantomData for API Safety
// Connection<Closed> vs Connection<Open> — can't query a closed connection
use std::marker::PhantomData;
// === Approach 1: Type-state pattern with PhantomData ===
struct Closed;
struct Open;
struct Connection<State> {
host: String,
_state: PhantomData<State>,
}
impl Connection<Closed> {
fn new(host: &str) -> Self {
Connection {
host: host.to_string(),
_state: PhantomData,
}
}
fn open(self) -> Connection<Open> {
println!("Connecting to {}...", self.host);
Connection {
host: self.host,
_state: PhantomData,
}
}
}
impl Connection<Open> {
fn query(&self, sql: &str) -> String {
format!("result({}): {}", self.host, sql)
}
fn execute(&self, sql: &str) -> usize {
println!("Execute on {}: {}", self.host, sql);
1 // rows affected
}
fn close(self) -> Connection<Closed> {
println!("Closing {}", self.host);
Connection {
host: self.host,
_state: PhantomData,
}
}
}
// host() available in any state
impl<S> Connection<S> {
fn host(&self) -> &str {
&self.host
}
}
// === Approach 2: Builder pattern with type states ===
struct Disconnected;
struct Connected;
struct InTransaction;
struct DbSession<State> {
url: String,
_state: PhantomData<State>,
}
impl DbSession<Disconnected> {
fn new(url: &str) -> Self {
DbSession {
url: url.to_string(),
_state: PhantomData,
}
}
fn connect(self) -> DbSession<Connected> {
DbSession {
url: self.url,
_state: PhantomData,
}
}
}
impl DbSession<Connected> {
fn begin_transaction(self) -> DbSession<InTransaction> {
DbSession {
url: self.url,
_state: PhantomData,
}
}
fn query(&self, sql: &str) -> String {
format!("query({}): {}", self.url, sql)
}
fn disconnect(self) -> DbSession<Disconnected> {
DbSession {
url: self.url,
_state: PhantomData,
}
}
}
impl DbSession<InTransaction> {
fn query(&self, sql: &str) -> String {
format!("tx_query({}): {}", self.url, sql)
}
fn commit(self) -> DbSession<Connected> {
println!("COMMIT");
DbSession {
url: self.url,
_state: PhantomData,
}
}
fn rollback(self) -> DbSession<Connected> {
println!("ROLLBACK");
DbSession {
url: self.url,
_state: PhantomData,
}
}
}
// === Approach 3: File handle safety ===
struct Unopened;
struct Opened;
struct SafeFile<State> {
path: String,
content: Option<String>,
_state: PhantomData<State>,
}
impl SafeFile<Unopened> {
fn new(path: &str) -> Self {
SafeFile {
path: path.to_string(),
content: None,
_state: PhantomData,
}
}
fn open(self) -> SafeFile<Opened> {
SafeFile {
path: self.path,
content: Some(String::new()),
_state: PhantomData,
}
}
}
impl SafeFile<Opened> {
fn write(&mut self, data: &str) {
if let Some(ref mut c) = self.content {
c.push_str(data);
}
}
fn read(&self) -> &str {
self.content.as_deref().unwrap_or("")
}
fn close(self) -> SafeFile<Unopened> {
SafeFile {
path: self.path,
content: None,
_state: PhantomData,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_connection_lifecycle() {
let conn = Connection::<Closed>::new("db.test");
assert_eq!(conn.host(), "db.test");
let conn = conn.open();
assert_eq!(conn.query("SELECT 1"), "result(db.test): SELECT 1");
let closed = conn.close();
assert_eq!(closed.host(), "db.test");
}
#[test]
fn test_db_session() {
let s = DbSession::new("pg://localhost").connect();
assert!(s.query("X").contains("query"));
let tx = s.begin_transaction();
assert!(tx.query("X").contains("tx_query"));
let s = tx.commit();
let _d = s.disconnect();
}
#[test]
fn test_safe_file() {
let f = SafeFile::<Unopened>::new("test.txt");
let mut f = f.open();
f.write("abc");
f.write("def");
assert_eq!(f.read(), "abcdef");
let _closed = f.close();
}
}
✓ Tests
Rust test suite
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_connection_lifecycle() {
let conn = Connection::<Closed>::new("db.test");
assert_eq!(conn.host(), "db.test");
let conn = conn.open();
assert_eq!(conn.query("SELECT 1"), "result(db.test): SELECT 1");
let closed = conn.close();
assert_eq!(closed.host(), "db.test");
}
#[test]
fn test_db_session() {
let s = DbSession::new("pg://localhost").connect();
assert!(s.query("X").contains("query"));
let tx = s.begin_transaction();
assert!(tx.query("X").contains("tx_query"));
let s = tx.commit();
let _d = s.disconnect();
}
#[test]
fn test_safe_file() {
let f = SafeFile::<Unopened>::new("test.txt");
let mut f = f.open();
f.write("abc");
f.write("def");
assert_eq!(f.read(), "abcdef");
let _closed = f.close();
}
}
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.