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:
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!
Now use it safely:
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!
// Usage throughout the codebase
if !self.root.changelog.exists
let content = self.root.changelog.read_to_string?;
self.root.ci_cache.write?;
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 = new?;
let parent = dir.parent; // Returns Option<GenericDir>
if let Some = parent
tree-type Solution - Exact Parent Types:
tree_type!
let project = new?;
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?
tree-type Solution - Typed Children:
tree_type!
let src = project.src;
// children() returns an iterator of the correct types!
for child in src.children
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!
// Type-safe navigation with runtime IDs
let app_data = new?;
let owner_dir = app_data.repos.owner;
let repo_dir = owner_dir.name;
let metadata: RepoMetadataFile = repo_dir.metadata.file;
let issue_dir = repo_dir.metadata.issues.issue;
let issue_file: RepoIssueFile = issue_dir.file;
// Each level is still type-safe!
assert_eq!;
Benefits:
- ✅ Runtime flexibility with compile-time safety
- ✅ Nested dynamic IDs (multiple levels) - unlimited with
codegen-v2feature - ✅ 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!
// Add custom methods to generated types
// Use your custom methods
let project = new?;
let version = project.cargo.parse_version?;
project.cargo.update_version?;
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):
After (tree-type):
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:
- Compile-time validation - Catch path errors before runtime
- IDE autocomplete - Discover available paths as you type
- Refactoring safety - Rename types, compiler finds all uses
- Impossible to typo - Filenames defined once, used safely everywhere
- Type-safe navigation -
parent()returns exact types, no Option needed - Dynamic flexibility - Runtime IDs with compile-time structure
- 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
[]
= "0.4"
# Optional features
# tree-type = { version = "0.4", features = ["serde", "enhanced-errors", "walk", "pattern-validation", "codegen-v2"] }
use tree_type;
tree_type!
let project = new?;
project.sync?; // Creates all directories and required files
Optional Features:
serde- Serialize/deserialize supportenhanced-errors- Better error messages with contextwalk- Directory traversal methodspattern-validation- Regex validation for dynamic IDscodegen-v2- Unlimited dynamic ID nesting depth
Learn more:
- Crate: crates.io/crates/tree-type
- Docs: docs.rs/tree-type
- Source: codeberg.org/kemitix/tree-type
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?