ExamplesBy LevelBy TopicLearning Paths
737 Advanced

737-typestate-file-handle — Typestate File Handle

Functional Programming

Tutorial

The Problem

File handles have permission semantics: writing to a read-only file, or reading from a write-only file, are runtime errors in most systems. The standard library's File type catches these at runtime via OS error codes. The typestate pattern promotes permission violations to compile-time errors: FileHandle<ReadOnly> simply does not implement write_all, so calling it is caught by the type checker before the program runs. This approach is used in embedded HAL (hardware abstraction layer) crates to prevent writing to read-only hardware registers.

🎯 Learning Outcomes

  • • Encode file permissions (Closed, ReadWrite, ReadOnly) as phantom type parameters
  • • Implement write_all only for FileHandle<ReadWrite> and read_to_string for both readable modes
  • • Transition between modes by consuming the handle: open_rw and open_ro return different types
  • • Combine typestate with io::Result for error propagation without losing permission information
  • • See how close() consumes any open handle and returns a FileHandle<Closed>
  • Code Example

    #![allow(clippy::all)]
    use std::io::{self, Read, Write};
    /// 737: File Handle Typestate — Open / Closed / ReadOnly
    /// Demonstrates compile-time permission encoding for file handles.
    use std::marker::PhantomData;
    
    // ── Permission markers ────────────────────────────────────────────────────────
    
    pub struct Closed;
    pub struct ReadWrite;
    pub struct ReadOnly;
    
    // ── File Handle ────────────────────────────────────────────────────────────────
    
    pub struct FileHandle<Mode> {
        path: String,
        content: Vec<u8>, // simulates in-memory file for this example
        pos: usize,
        _mode: PhantomData<Mode>,
    }
    
    impl FileHandle<Closed> {
        /// Create a new closed handle.
        pub fn new(path: impl Into<String>) -> Self {
            FileHandle {
                path: path.into(),
                content: Vec::new(),
                pos: 0,
                _mode: PhantomData,
            }
        }
    
        /// Open for reading and writing.
        pub fn open_rw(self) -> io::Result<FileHandle<ReadWrite>> {
            println!("Opening '{}' read-write", self.path);
            Ok(FileHandle {
                path: self.path,
                content: self.content,
                pos: 0,
                _mode: PhantomData,
            })
        }
    
        /// Open existing content as read-only.
        pub fn open_ro(self, initial: Vec<u8>) -> io::Result<FileHandle<ReadOnly>> {
            println!("Opening '{}' read-only", self.path);
            Ok(FileHandle {
                path: self.path,
                content: initial,
                pos: 0,
                _mode: PhantomData,
            })
        }
    }
    
    impl FileHandle<ReadWrite> {
        pub fn write_all(&mut self, data: &[u8]) -> io::Result<()> {
            self.content.extend_from_slice(data);
            println!("Wrote {} bytes to '{}'", data.len(), self.path);
            Ok(())
        }
    
        pub fn read_to_string(&mut self) -> io::Result<String> {
            let s = String::from_utf8_lossy(&self.content[self.pos..]).into_owned();
            self.pos = self.content.len();
            Ok(s)
        }
    
        /// Downgrade to read-only (cannot write anymore)
        pub fn into_readonly(self) -> FileHandle<ReadOnly> {
            println!("Downgrading '{}' to read-only", self.path);
            FileHandle {
                path: self.path,
                content: self.content,
                pos: self.pos,
                _mode: PhantomData,
            }
        }
    
        /// Close the handle — transitions to Closed.
        pub fn close(self) -> FileHandle<Closed> {
            println!("Closing '{}'", self.path);
            FileHandle {
                path: self.path,
                content: Vec::new(),
                pos: 0,
                _mode: PhantomData,
            }
        }
    }
    
    impl FileHandle<ReadOnly> {
        pub fn read_to_string(&mut self) -> io::Result<String> {
            let s = String::from_utf8_lossy(&self.content[self.pos..]).into_owned();
            self.pos = self.content.len();
            Ok(s)
        }
    
        pub fn close(self) -> FileHandle<Closed> {
            println!("Closing '{}' (read-only)", self.path);
            FileHandle {
                path: self.path,
                content: Vec::new(),
                pos: 0,
                _mode: PhantomData,
            }
        }
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn write_then_read() {
            let handle = FileHandle::<Closed>::new("test.txt");
            let mut rw = handle.open_rw().unwrap();
            rw.write_all(b"hello world").unwrap();
            let s = rw.read_to_string().unwrap();
            assert_eq!(s, "hello world");
            rw.close();
        }
    
        #[test]
        fn downgrade_to_readonly() {
            let handle = FileHandle::<Closed>::new("test.txt");
            let mut rw = handle.open_rw().unwrap();
            rw.write_all(b"data").unwrap();
            let mut ro = rw.into_readonly();
            let s = ro.read_to_string().unwrap();
            assert_eq!(s, "data");
            ro.close();
        }
    
        #[test]
        fn open_ro_with_initial_content() {
            let handle = FileHandle::<Closed>::new("test.txt");
            let mut ro = handle.open_ro(b"preloaded".to_vec()).unwrap();
            let s = ro.read_to_string().unwrap();
            assert_eq!(s, "preloaded");
            ro.close();
        }
    }

    Key Differences

  • Standard library: OCaml's stdlib uses separate in_channel/out_channel types (a coarser approach); Rust's File uses runtime OpenOptions flags.
  • Composability: Rust's single FileHandle<Mode> type with phantom parameter is more composable than OCaml's two separate types when adding new modes.
  • Upgrades: Rust typestate prevents upgrading a ReadOnly to ReadWrite without going through Closed; OCaml achieves the same via module abstraction.
  • Embedded: Rust's approach is widely used in embedded-hal for GPIO pin modes (Input, Output, Alternate); OCaml has no equivalent embedded ecosystem.
  • OCaml Approach

    OCaml's open_in and open_out return distinct in_channel and out_channel types — a simpler but less composable form of permission encoding. More expressive permission systems use GADTs or phantom type variables with abstract module signatures. The Bos (Basic OS) library uses a similar approach for file system operations with typed path permissions.

    Full Source

    #![allow(clippy::all)]
    use std::io::{self, Read, Write};
    /// 737: File Handle Typestate — Open / Closed / ReadOnly
    /// Demonstrates compile-time permission encoding for file handles.
    use std::marker::PhantomData;
    
    // ── Permission markers ────────────────────────────────────────────────────────
    
    pub struct Closed;
    pub struct ReadWrite;
    pub struct ReadOnly;
    
    // ── File Handle ────────────────────────────────────────────────────────────────
    
    pub struct FileHandle<Mode> {
        path: String,
        content: Vec<u8>, // simulates in-memory file for this example
        pos: usize,
        _mode: PhantomData<Mode>,
    }
    
    impl FileHandle<Closed> {
        /// Create a new closed handle.
        pub fn new(path: impl Into<String>) -> Self {
            FileHandle {
                path: path.into(),
                content: Vec::new(),
                pos: 0,
                _mode: PhantomData,
            }
        }
    
        /// Open for reading and writing.
        pub fn open_rw(self) -> io::Result<FileHandle<ReadWrite>> {
            println!("Opening '{}' read-write", self.path);
            Ok(FileHandle {
                path: self.path,
                content: self.content,
                pos: 0,
                _mode: PhantomData,
            })
        }
    
        /// Open existing content as read-only.
        pub fn open_ro(self, initial: Vec<u8>) -> io::Result<FileHandle<ReadOnly>> {
            println!("Opening '{}' read-only", self.path);
            Ok(FileHandle {
                path: self.path,
                content: initial,
                pos: 0,
                _mode: PhantomData,
            })
        }
    }
    
    impl FileHandle<ReadWrite> {
        pub fn write_all(&mut self, data: &[u8]) -> io::Result<()> {
            self.content.extend_from_slice(data);
            println!("Wrote {} bytes to '{}'", data.len(), self.path);
            Ok(())
        }
    
        pub fn read_to_string(&mut self) -> io::Result<String> {
            let s = String::from_utf8_lossy(&self.content[self.pos..]).into_owned();
            self.pos = self.content.len();
            Ok(s)
        }
    
        /// Downgrade to read-only (cannot write anymore)
        pub fn into_readonly(self) -> FileHandle<ReadOnly> {
            println!("Downgrading '{}' to read-only", self.path);
            FileHandle {
                path: self.path,
                content: self.content,
                pos: self.pos,
                _mode: PhantomData,
            }
        }
    
        /// Close the handle — transitions to Closed.
        pub fn close(self) -> FileHandle<Closed> {
            println!("Closing '{}'", self.path);
            FileHandle {
                path: self.path,
                content: Vec::new(),
                pos: 0,
                _mode: PhantomData,
            }
        }
    }
    
    impl FileHandle<ReadOnly> {
        pub fn read_to_string(&mut self) -> io::Result<String> {
            let s = String::from_utf8_lossy(&self.content[self.pos..]).into_owned();
            self.pos = self.content.len();
            Ok(s)
        }
    
        pub fn close(self) -> FileHandle<Closed> {
            println!("Closing '{}' (read-only)", self.path);
            FileHandle {
                path: self.path,
                content: Vec::new(),
                pos: 0,
                _mode: PhantomData,
            }
        }
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn write_then_read() {
            let handle = FileHandle::<Closed>::new("test.txt");
            let mut rw = handle.open_rw().unwrap();
            rw.write_all(b"hello world").unwrap();
            let s = rw.read_to_string().unwrap();
            assert_eq!(s, "hello world");
            rw.close();
        }
    
        #[test]
        fn downgrade_to_readonly() {
            let handle = FileHandle::<Closed>::new("test.txt");
            let mut rw = handle.open_rw().unwrap();
            rw.write_all(b"data").unwrap();
            let mut ro = rw.into_readonly();
            let s = ro.read_to_string().unwrap();
            assert_eq!(s, "data");
            ro.close();
        }
    
        #[test]
        fn open_ro_with_initial_content() {
            let handle = FileHandle::<Closed>::new("test.txt");
            let mut ro = handle.open_ro(b"preloaded".to_vec()).unwrap();
            let s = ro.read_to_string().unwrap();
            assert_eq!(s, "preloaded");
            ro.close();
        }
    }
    ✓ Tests Rust test suite
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn write_then_read() {
            let handle = FileHandle::<Closed>::new("test.txt");
            let mut rw = handle.open_rw().unwrap();
            rw.write_all(b"hello world").unwrap();
            let s = rw.read_to_string().unwrap();
            assert_eq!(s, "hello world");
            rw.close();
        }
    
        #[test]
        fn downgrade_to_readonly() {
            let handle = FileHandle::<Closed>::new("test.txt");
            let mut rw = handle.open_rw().unwrap();
            rw.write_all(b"data").unwrap();
            let mut ro = rw.into_readonly();
            let s = ro.read_to_string().unwrap();
            assert_eq!(s, "data");
            ro.close();
        }
    
        #[test]
        fn open_ro_with_initial_content() {
            let handle = FileHandle::<Closed>::new("test.txt");
            let mut ro = handle.open_ro(b"preloaded".to_vec()).unwrap();
            let s = ro.read_to_string().unwrap();
            assert_eq!(s, "preloaded");
            ro.close();
        }
    }

    Exercises

  • Add a WriteOnly mode that supports write_all but not read_all, and implement open_wo on FileHandle<Closed>.
  • Implement seek(&mut self, pos: usize) on FileHandle<ReadWrite> and FileHandle<ReadOnly> using a shared trait Seekable.
  • Write a function copy<Src, Dst>(src: &mut FileHandle<Src>, dst: &mut FileHandle<Dst>) constrained to only work when Src: Readable and Dst: Writable.
  • Open Source Repos