737-typestate-file-handle — Typestate File Handle
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
Closed, ReadWrite, ReadOnly) as phantom type parameterswrite_all only for FileHandle<ReadWrite> and read_to_string for both readable modesopen_rw and open_ro return different typesio::Result for error propagation without losing permission informationclose() 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
in_channel/out_channel types (a coarser approach); Rust's File uses runtime OpenOptions flags.FileHandle<Mode> type with phantom parameter is more composable than OCaml's two separate types when adding new modes.ReadOnly to ReadWrite without going through Closed; OCaml achieves the same via module abstraction.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();
}
}#[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
WriteOnly mode that supports write_all but not read_all, and implement open_wo on FileHandle<Closed>.seek(&mut self, pos: usize) on FileHandle<ReadWrite> and FileHandle<ReadOnly> using a shared trait Seekable.copy<Src, Dst>(src: &mut FileHandle<Src>, dst: &mut FileHandle<Dst>) constrained to only work when Src: Readable and Dst: Writable.