tree-type: Type-Safe Filesystem Paths for Rust

I just presented the following Lightning Talk for my tree-type crate.


The Problem: String-Based Paths Are Error-Prone

When working with filesystem paths in code, we typically use strings:

fn update_project(root: &Path) -> Result<()> {
    // Easy to make typos
    let changelog = root.join("CHANGLOG.md");  // Oops! Missing 'E'
    
    // Easy to get case wrong
    let cargo = root.join("cargo.toml");  // Should be "Cargo.toml"
    
    // No IDE autocomplete
    let config = root.join("config").join("settings.toml");
    
    // Errors discovered at runtime (or in production!)
    let content = fs::read_to_string(&changelog)?;  // Runtime error!
}

Problems:

  • ❌ Typos caught at runtime
  • ❌ No IDE autocomplete
  • ❌ Easy to mix up similar paths
  • ❌ Refactoring is error-prone
  • ❌ No compile-time validation

The Solution: tree-type

Define your project structure once, get type safety everywhere:

use tree_type::tree_type;

tree_type! {
    CurrentRoot {
        changelog("CHANGELOG.md") as ChangeLogFile,
        cargo("Cargo.toml") as CargoFile,
        config/ {
            settings("settings.toml") as SettingsFile,
        }
    }
}

Now use it safely:

fn update_project(root: &CurrentRoot) -> Result<()> {
    // Type-safe access - impossible to typo!
    let changelog: ChangeLogFile = root.changelog();
    
    // IDE autocomplete works
    let cargo: CargoFile = root.cargo();
    
    // Nested navigation is type-safe
    let settings: SettingsFile = root.config().settings();
    
    // Compiler catches mistakes before code runs
    let content = changelog.read_to_string()?;
}

Benefits:

  • ✅ Compile-time path validation
  • ✅ IDE autocomplete for all paths
  • ✅ Impossible to typo filenames
  • ✅ Refactoring is safe
  • ✅ Each path has its own type

Real Example: Cairn Project

From the cairn release automation tool:

tree_type! {
    CurrentRoot {
        jj/(".jj") as JujutsuRepoDir,
        git/(".git") as GitRepoDir,
        changelog("CHANGELOG.md") as ChangeLogFile,
        cargo("Cargo.toml") as CargoFile,
        ci_cache(".cairn-ci-cache") as CiCacheFile,
    }
}

// Usage throughout the codebase
if !self.root.changelog().exists() {
    return Err("CHANGELOG.md not found");
}

let content = self.root.changelog().read_to_string()?;
self.root.ci_cache().write(&cache_data)?;

Each file has its own type with built-in methods:

  • .read_to_string() - Read file contents
  • .write() - Write to file
  • .exists() - Check existence
  • .remove() - Delete file
  • And more…

Feature: Type-Safe Navigation with parent()

The Problem with Generic Paths:

// Generic approach - returns Option
let dir = GenericDir::new("/path/to/dir")?;
let parent = dir.parent();  // Returns Option<GenericDir>

if let Some(p) = parent {
    // Have to handle None case
}

tree-type Solution - Exact Parent Types:

tree_type! {
    ProjectRoot {
        src/ {
            lib("lib.rs") as LibFile,
            main("main.rs") as MainFile,
        }
    }
}

let project = ProjectRoot::new("/project")?;
let src = project.src();
let lib_file = src.lib();

// parent() returns exact parent type - no Option!
let src_dir: ProjectRootSrc = lib_file.parent();
let project_root: ProjectRoot = src_dir.parent();

Why this matters:

  • Compiler knows the structure
  • No runtime checks needed
  • Can’t accidentally navigate to wrong parent
  • Type system enforces correct relationships

Feature: Type-Safe Directory Iteration with children()

The Problem with read_dir():

// Generic read_dir returns untyped entries
for entry in src_dir.read_dir()? {
    let entry = entry?;
    // What type is this? File? Directory? Which one?
    // No type safety, have to check at runtime
}

tree-type Solution - Typed Children:

tree_type! {
    ProjectRoot {
        src/ {
            lib("lib.rs") as LibFile,
            main("main.rs") as MainFile,
            utils("utils.rs") as UtilsFile,
        }
    }
}

let src = project.src();

// children() returns an iterator of the correct types!
for child in src.children() {
    match child {
        ProjectRootSrcChild::Lib(lib_file) => {
            // lib_file is LibFile type
            println!("Found lib: {}", lib_file.as_path().display());
        }
        ProjectRootSrcChild::Main(main_file) => {
            // main_file is MainFile type
            println!("Found main: {}", main_file.as_path().display());
        }
        ProjectRootSrcChild::Utils(utils_file) => {
            // utils_file is UtilsFile type
            println!("Found utils: {}", utils_file.as_path().display());
        }
    }
}

Benefits:

  • ✅ Each child has its correct type
  • ✅ Exhaustive pattern matching
  • ✅ Compiler ensures you handle all cases
  • ✅ No runtime type checking needed

Feature: Dynamic IDs for Runtime Paths

Sometimes you need paths determined at runtime. Here’s a real example from fireforge:

tree_type! {
    AppDataDir {
        repos/ as AppReposDir {
            [owner: RepositoryOwner]/ as RepoOwnerDir {
                [name: RepositoryName]/ as RepoBaseDir {
                    metadata/ as RepoMetadataDir {
                        file("info.json") as RepoMetadataFile,
                        issues/ as RepoIssuesDir {
                            [issue: IssueId]/ as RepoIssueDir {
                                file("issue.md") as RepoIssueFile,
                            }
                        }
                    }
                }
            }
        }
    }
}

// Type-safe navigation with runtime IDs
let app_data = AppDataDir::new("/data")?;

let owner_dir = app_data.repos().owner("kemitix");
let repo_dir = owner_dir.name("tree-type");
let metadata: RepoMetadataFile = repo_dir.metadata().file();
let issue_dir = repo_dir.metadata().issues().issue(42);
let issue_file: RepoIssueFile = issue_dir.file();

// Each level is still type-safe!
assert_eq!(
    issue_file.as_path(), 
    Path::new("/data/repos/kemitix/tree-type/metadata/issues/42/issue.md")
);

Benefits:

  • ✅ Runtime flexibility with compile-time safety
  • ✅ Nested dynamic IDs (multiple levels) - unlimited with codegen-v2 feature
  • ✅ Type-safe navigation at every level
  • ✅ IDE autocomplete still works

Note: Default code generation supports up to 3 levels of dynamic ID nesting. Enable the codegen-v2 feature for unlimited nesting depth.


Feature: Extending Generated Types with Custom Methods

Since generated types exist in your crate, you can add your own methods:

tree_type! {
    ProjectRoot {
        cargo("Cargo.toml") as CargoFile,
        src/ as SrcDir {
            lib("lib.rs") as LibFile,
        }
    }
}

// Add custom methods to generated types
impl CargoFile {
    pub fn parse_version(&self) -> Result<String> {
        let content = self.read_to_string()?;
        // Parse version from Cargo.toml
        Ok(extract_version(&content))
    }
    
    pub fn update_version(&self, new_version: &str) -> Result<()> {
        let content = self.read_to_string()?;
        let updated = replace_version(&content, new_version);
        self.write(&updated)
    }
}

impl SrcDir {
    pub fn count_rust_files(&self) -> Result<usize> {
        Ok(self.read_dir()?.filter(|e| e.path().extension() == Some("rs")).count())
    }
}

// Use your custom methods
let project = ProjectRoot::new("/project")?;
let version = project.cargo().parse_version()?;
project.cargo().update_version("0.2.0")?;
let file_count = project.src().count_rust_files()?;

Benefits:

  • ✅ Domain-specific operations on your types
  • ✅ Encapsulate business logic with filesystem structure
  • ✅ Type safety extends to your custom methods
  • ✅ Clean API that combines navigation and operations

Feature: File Operations with Compile-Time Safety

Before (string-based):

fn process_files(root: &Path) -> Result<()> {
    // Can pass wrong file to wrong function
    let changelog = root.join("CHANGELOG.md");
    let cargo = root.join("Cargo.toml");
    
    // Compiler can't help if you mix them up
    parse_cargo(&changelog)?;  // Runtime error!
    update_changelog(&cargo)?;  // Runtime error!
}

After (tree-type):

fn process_files(root: &CurrentRoot) -> Result<()> {
    let changelog: ChangeLogFile = root.changelog();
    let cargo: CargoFile = root.cargo();
    
    // Compiler enforces correct types
    parse_cargo(&cargo)?;           // ✅ Correct
    update_changelog(&changelog)?;  // ✅ Correct
    
    // These won't compile:
    // parse_cargo(&changelog)?;     // ❌ Compile error!
    // update_changelog(&cargo)?;    // ❌ Compile error!
}

fn parse_cargo(file: &CargoFile) -> Result<()> { /* ... */ }
fn update_changelog(file: &ChangeLogFile) -> Result<()> { /* ... */ }

What you get:

  • Different types for different files
  • Can’t accidentally pass wrong file to wrong function
  • Compiler catches mistakes before code runs
  • Refactoring is safe - rename type, compiler finds all uses

Summary: Why tree-type?

Type Safety Benefits:

  1. Compile-time validation - Catch path errors before runtime
  2. IDE autocomplete - Discover available paths as you type
  3. Refactoring safety - Rename types, compiler finds all uses
  4. Impossible to typo - Filenames defined once, used safely everywhere
  5. Type-safe navigation - parent() returns exact types, no Option needed
  6. Dynamic flexibility - Runtime IDs with compile-time structure
  7. Function safety - Can’t pass wrong file type to wrong function

When to use:

  • ✅ Projects with fixed directory structures
  • ✅ Build tools and automation
  • ✅ Configuration management
  • ✅ Test fixtures and data directories
  • ✅ Any code that works with known filesystem layouts

When NOT to use:

  • ❌ Completely dynamic/unknown directory structures
  • ❌ User-provided arbitrary paths
  • ❌ Simple scripts with 1-2 paths

Getting Started

[dependencies]
tree-type = "0.4"

# Optional features
# tree-type = { version = "0.4", features = ["serde", "enhanced-errors", "walk", "pattern-validation", "codegen-v2"] }
use tree_type::tree_type;

tree_type! {
    MyProject {
        src/,
        target/,
        readme("README.md")
    }
}

let project = MyProject::new(project_root)?;
project.sync()?;  // Creates all directories and required files

Optional Features:

  • serde - Serialize/deserialize support
  • enhanced-errors - Better error messages with context
  • walk - Directory traversal methods
  • pattern-validation - Regex validation for dynamic IDs
  • codegen-v2 - Unlimited dynamic ID nesting depth

Learn more:


Q&A - Anticipated Questions

Q: Does this work with existing codebases? A: Yes! You can gradually adopt tree-type. Define types for your structure, then refactor incrementally. Old string-based code continues to work.

Q: What about performance? A: Zero runtime overhead. tree-type is a compile-time abstraction - generated code is as efficient as hand-written PathBuf operations.

Q: Can I use this with other languages? A: tree-type is Rust-specific (uses procedural macros). The concept could be adapted to other languages with strong type systems (TypeScript, Kotlin, etc.).

Q: What if my directory structure changes? A: Update the tree_type! definition, and the compiler will show you everywhere that needs updating. This is a feature - you can’t forget to update code!

Q: Does it work on Windows? A: Yes! tree-type uses Rust’s standard Path/PathBuf which handle platform differences automatically.

Q: Can I have optional files/directories? A: Yes! Use #[required] attribute for must-exist paths, or check .exists() for optional ones. The sync() method only creates required paths.

Q: What about symbolic links? A: Supported with #[symlink(target)] attribute (Unix only). tree-type handles symlink creation and validation.


Questions?