๐Ÿฆ€ Functional Rust

737: File Handle Typestate: Open/Closed/ReadOnly

Difficulty: 4 Level: Expert Encode file permissions โ€” Closed, ReadWrite, ReadOnly โ€” in the type system so that writing to a read-only handle or reading from a closed one is a compile error, not a runtime exception.

The Problem This Solves

File handles have permissions. Writing to a read-only file, reading from a closed handle, or closing a handle that was already closed are all bugs. In most languages these are runtime errors: `EBADF`, `PermissionError`, or `IOException`. You write defensive guards at every call site, and the guards still miss edge cases. In Rust, you can encode the handle's current permission in its type. A `FileHandle<ReadOnly>` has a `read_to_string()` method but no `write_all()`. A `FileHandle<Closed>` has neither โ€” you can't accidentally read from it because the method simply doesn't exist on that type. This pattern appears in real Rust APIs: `std::fs::File` distinguishes open/close state; `BufWriter` tracks whether you've flushed; database transaction wrappers distinguish started/committed/rolled-back. Understanding the pattern lets you design APIs that eliminate whole classes of bugs.

The Intuition

Three marker types โ€” `Closed`, `ReadWrite`, `ReadOnly` โ€” encode the current access mode. `FileHandle<Mode>` is generic over the mode. Methods are defined on specific instantiations: The downgrade from `ReadWrite` to `ReadOnly` is a consuming operation: you can't write anymore, and the type makes that permanent. No runtime flag, no mutex, no option โ€” the permission is structurally encoded.

How It Works in Rust

use std::marker::PhantomData;

pub struct Closed;
pub struct ReadWrite;
pub struct ReadOnly;

pub struct FileHandle<Mode> {
 path:    String,
 content: Vec<u8>,
 pos:     usize,
 _mode:   PhantomData<Mode>,  // zero bytes โ€” only affects type checking
}

impl FileHandle<Closed> {
 pub fn new(path: impl Into<String>) -> Self { /* ... */ }

 // Open read-write โ€” Closed โ†’ ReadWrite
 pub fn open_rw(self) -> io::Result<FileHandle<ReadWrite>> { /* ... */ }

 // Open read-only with initial content โ€” Closed โ†’ ReadOnly
 pub fn open_ro(self, initial: Vec<u8>) -> io::Result<FileHandle<ReadOnly>> { /* ... */ }
}

impl FileHandle<ReadWrite> {
 pub fn write_all(&mut self, data: &[u8]) -> io::Result<()> { /* ... */ }
 pub fn read_to_string(&mut self) -> io::Result<String> { /* ... */ }

 // Permission downgrade: ReadWrite โ†’ ReadOnly (consuming, irreversible)
 pub fn into_readonly(self) -> FileHandle<ReadOnly> { /* ... */ }

 // Close: ReadWrite โ†’ Closed
 pub fn close(self) -> FileHandle<Closed> { /* ... */ }
}

impl FileHandle<ReadOnly> {
 pub fn read_to_string(&mut self) -> io::Result<String> { /* ... */ }
 // No write_all() here โ€” ReadOnly genuinely cannot write
 pub fn close(self) -> FileHandle<Closed> { /* ... */ }
}

// โ”€โ”€ Valid lifecycle โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
let handle = FileHandle::<Closed>::new("notes.txt");
let mut rw = handle.open_rw()?;
rw.write_all(b"Hello, typestate!")?;
let content = rw.read_to_string()?;

let mut ro = rw.into_readonly();     // type changes; write_all() vanishes
let _re_read = ro.read_to_string()?; // still readable
let _closed = ro.close();

// โ”€โ”€ Compile errors for permission violations โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// ro.write_all(b"forbidden");  // error: no method `write_all` on FileHandle<ReadOnly>
// _closed.read_to_string();    // error: no method `read_to_string` on FileHandle<Closed>

What This Unlocks

Key Differences

ConceptOCamlRust
File permission modeRuntime `open_flag` (Unix); runtime exception on violationCompile-time phantom type โ€” wrong-mode method doesn't exist
Read-only enforcement`In_channel` vs `Out_channel` separate typesUnified `FileHandle<Mode>` with mode-specific methods
Permission downgradeChange runtime flags; risk of misuse`into_readonly()` โ€” consuming; write methods disappear from type
Close enforcementNo enforcement; double-close is silent`Closed` type has no read/write methods; compiler prevents misuse
/// 737: File Handle Typestate โ€” Open / Closed / ReadOnly
/// Demonstrates compile-time permission encoding for file handles.

use std::marker::PhantomData;
use std::io::{self, Read, Write};

// โ”€โ”€ 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,
        }
    }
}

fn main() {
    // Full lifecycle: closed โ†’ rw โ†’ readonly โ†’ closed
    let handle = FileHandle::<Closed>::new("notes.txt");
    let mut rw = handle.open_rw().unwrap();
    rw.write_all(b"Hello, typestate!").unwrap();
    let contents = rw.read_to_string().unwrap();
    println!("Contents: {}", contents);

    let mut ro = rw.into_readonly();
    // ro.write_all(b"forbidden"); // COMPILE ERROR: method not found on ReadOnly

    let re_read = ro.read_to_string().unwrap();
    println!("Re-read: {}", re_read);

    let _closed = ro.close();
    // _closed.read_to_string(); // COMPILE ERROR: method not found on Closed
}

#[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();
    }
}
(* 737: File Handle Typestate โ€” OCaml already has this!
   OCaml separates in_channel (read-only) and out_channel (write-only) at the type level.
   This IS the typestate pattern, built into the language. *)

(* Read-only handle โ€” in_channel *)
let demo_read filename =
  let ic = open_in filename in
  (try
    let line = input_line ic in
    Printf.printf "Read: %s\n" line
    (* ic.output_string "cannot write" โ† TYPE ERROR: no such method on in_channel *)
  with End_of_file -> ());
  close_in ic

(* Write-only handle โ€” out_channel *)
let demo_write filename =
  let oc = open_out filename in
  output_string oc "Hello from OCaml typestate!\n";
  (* input_line oc โ† TYPE ERROR: no such method on out_channel *)
  close_out oc

let () =
  let tmpfile = Filename.temp_file "typestate_" ".txt" in
  demo_write tmpfile;
  demo_read tmpfile;
  Sys.remove tmpfile;
  Printf.printf "Done. OCaml's in_channel/out_channel = typestate pattern!\n"