ExamplesBy LevelBy TopicLearning Paths
180 Advanced

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

  • • Apply the typestate pattern to a real-world resource lifecycle (database connection)
  • • Understand how phantom types prevent use-after-close and use-before-open bugs at compile time
  • • See how the same pattern applies to file handles, sockets, and other OS resources
  • • Appreciate zero-cost typestate: PhantomData<State> adds no runtime overhead
  • Code 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

  • Move semantics: Rust's open(self) consumes the closed connection — the old binding cannot be used; OCaml retains the old value in scope.
  • Compile-time prevention: Rust: using the old closed connection after open is a compile error ("value used after move"); OCaml: same value remains accessible.
  • Linear types: Rust's ownership system provides a subset of linear types; true linear type languages (Idris, Linear Haskell) are stricter still.
  • RAII: Rust can combine typestate with 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

  • Add an execute(&mut self, sql: &str) -> Result<(), String> method on Connection<Open> that simulates query execution.
  • Implement Connection<Open>Connection<InTransaction>Connection<Open> transitions with begin_transaction, commit, and rollback methods.
  • Combine typestate with Drop: auto-close the connection when Connection<Open> is dropped, logging a warning.
  • Open Source Repos