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

    //! 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

  • 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