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:- `write_all()` only on `FileHandle<ReadWrite>`
- `into_readonly()` โ `ReadWrite` โ `ReadOnly` (permission reduction, irreversible)
- `close()` on both `ReadWrite` and `ReadOnly` โ returns `FileHandle<Closed>`
- `read_to_string()` on both `ReadWrite` and `ReadOnly` (both can read)
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
- Permission-safe file APIs โ impossible to write to a read-only handle or read from a closed one; the error is in the IDE before you even compile.
- Capability downgrade โ `into_readonly()` is a permanent one-way transition; once downgraded, write access is structurally gone, not just runtime-checked.
- Generalises beyond files โ database connections with `Disconnected`/`InTransaction`/`Committed` states, network sockets, hardware peripherals โ any resource with mode-restricted operations.
Key Differences
| Concept | OCaml | Rust |
|---|---|---|
| File permission mode | Runtime `open_flag` (Unix); runtime exception on violation | Compile-time phantom type โ wrong-mode method doesn't exist |
| Read-only enforcement | `In_channel` vs `Out_channel` separate types | Unified `FileHandle<Mode>` with mode-specific methods |
| Permission downgrade | Change runtime flags; risk of misuse | `into_readonly()` โ consuming; write methods disappear from type |
| Close enforcement | No 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"