
Building a Typed File Path API in Rust
Why a typed path layer is useful
A raw PathBuf can represent almost anything, which is both its strength and its weakness. If a function expects an existing directory but receives a relative file path, the mistake may only surface deep inside the program.
A typed API helps you:
- validate assumptions early
- reduce repeated path checks
- make function signatures self-documenting
- separate “user input” from “trusted path”
This is especially useful in CLI tools, file processors, backup utilities, and services that read or write local files.
Design goals
We’ll build a small set of wrappers around PathBuf:
ExistingFile: a path that exists and points to a fileExistingDir: a path that exists and points to a directoryAbsolutePath: a path guaranteed to be absoluteWritableFile: a path intended for output, with parent directory validation
These types will be lightweight, cloneable, and easy to convert back to &Path when needed.
What we will not do
We will not attempt to model every filesystem rule. For example, symlink resolution, permissions, and race conditions are real concerns, but they require additional context and often platform-specific handling. The goal here is a clean, practical foundation.
Core error type
A typed path API should fail with precise errors. Instead of returning a generic io::Error everywhere, we can define a domain-specific error enum.
use std::fmt;
use std::path::{Path, PathBuf};
#[derive(Debug)]
pub enum PathError {
NotFound(PathBuf),
NotAFile(PathBuf),
NotADir(PathBuf),
NotAbsolute(PathBuf),
ParentMissing(PathBuf),
}
impl fmt::Display for PathError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PathError::NotFound(p) => write!(f, "path does not exist: {}", p.display()),
PathError::NotAFile(p) => write!(f, "path is not a file: {}", p.display()),
PathError::NotADir(p) => write!(f, "path is not a directory: {}", p.display()),
PathError::NotAbsolute(p) => write!(f, "path is not absolute: {}", p.display()),
PathError::ParentMissing(p) => {
write!(f, "parent directory does not exist: {}", p.display())
}
}
}
}
impl std::error::Error for PathError {}This error type gives callers enough information to recover, log, or present a useful message to users.
Implementing typed wrappers
Each wrapper stores a PathBuf internally and exposes only validated constructors.
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExistingFile(PathBuf);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExistingDir(PathBuf);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AbsolutePath(PathBuf);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WritableFile(PathBuf);Now we can implement constructors that enforce invariants.
impl ExistingFile {
pub fn new<P: AsRef<Path>>(path: P) -> Result<Self, PathError> {
let path = path.as_ref();
if !path.exists() {
return Err(PathError::NotFound(path.to_path_buf()));
}
if !path.is_file() {
return Err(PathError::NotAFile(path.to_path_buf()));
}
Ok(Self(path.to_path_buf()))
}
pub fn as_path(&self) -> &Path {
&self.0
}
}
impl ExistingDir {
pub fn new<P: AsRef<Path>>(path: P) -> Result<Self, PathError> {
let path = path.as_ref();
if !path.exists() {
return Err(PathError::NotFound(path.to_path_buf()));
}
if !path.is_dir() {
return Err(PathError::NotADir(path.to_path_buf()));
}
Ok(Self(path.to_path_buf()))
}
pub fn as_path(&self) -> &Path {
&self.0
}
}
impl AbsolutePath {
pub fn new<P: AsRef<Path>>(path: P) -> Result<Self, PathError> {
let path = path.as_ref();
if !path.is_absolute() {
return Err(PathError::NotAbsolute(path.to_path_buf()));
}
Ok(Self(path.to_path_buf()))
}
pub fn as_path(&self) -> &Path {
&self.0
}
}These types are intentionally simple. The constructor does the validation; the rest of the API stays minimal.
Validating output paths
Output paths are slightly different from input paths. A file may not exist yet, but its parent directory should usually exist before writing.
impl WritableFile {
pub fn new<P: AsRef<Path>>(path: P) -> Result<Self, PathError> {
let path = path.as_ref();
if let Some(parent) = path.parent() {
if !parent.exists() {
return Err(PathError::ParentMissing(parent.to_path_buf()));
}
if !parent.is_dir() {
return Err(PathError::NotADir(parent.to_path_buf()));
}
}
Ok(Self(path.to_path_buf()))
}
pub fn as_path(&self) -> &Path {
&self.0
}
}This is useful for export files, logs, generated reports, and temporary artifacts.
A real-world example: copying a file safely
Suppose you are writing a tool that copies one existing file into a destination directory. With typed paths, the function signature becomes explicit.
use std::fs;
use std::io;
pub fn copy_into_dir(src: ExistingFile, dest_dir: ExistingDir) -> io::Result<PathBuf> {
let file_name = src
.as_path()
.file_name()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "source has no file name"))?;
let dest = dest_dir.as_path().join(file_name);
fs::copy(src.as_path(), &dest)?;
Ok(dest)
}The function no longer needs to check whether src is a file or whether dest_dir is a directory. Those guarantees are already encoded in the types.
Usage
fn main() -> Result<(), Box<dyn std::error::Error>> {
let src = ExistingFile::new("input/report.txt")?;
let out_dir = ExistingDir::new("/tmp/archive")?;
let copied = copy_into_dir(src, out_dir)?;
println!("Copied to {}", copied.display());
Ok(())
}This style makes invalid states harder to represent and valid states easier to use.
Converting between path types
In many applications, you’ll want to derive one typed path from another. For example, you may want to turn an absolute path into an existing file after validation.
impl AbsolutePath {
pub fn into_existing_file(self) -> Result<ExistingFile, PathError> {
ExistingFile::new(self.0)
}
pub fn into_existing_dir(self) -> Result<ExistingDir, PathError> {
ExistingDir::new(self.0)
}
}You can also add From implementations for ergonomic conversions where no validation is needed.
impl From<ExistingFile> for PathBuf {
fn from(value: ExistingFile) -> Self {
value.0
}
}
impl From<ExistingDir> for PathBuf {
fn from(value: ExistingDir) -> Self {
value.0
}
}
impl From<AbsolutePath> for PathBuf {
fn from(value: AbsolutePath) -> Self {
value.0
}
}
impl From<WritableFile> for PathBuf {
fn from(value: WritableFile) -> Self {
value.0
}
}This lets you pass typed paths into APIs that still expect PathBuf.
Choosing the right wrapper
| Type | Invariant | Typical use |
|---|---|---|
ExistingFile | Exists and is a file | Reading input files, parsing assets |
ExistingDir | Exists and is a directory | Output folders, traversal roots |
AbsolutePath | Is absolute | System paths, canonicalized references |
WritableFile | Parent directory exists | Logs, exports, generated files |
A good rule is to choose the narrowest type that matches your function’s contract. Narrow types improve readability and reduce validation work.
Best practices for production code
Validate at the boundary
Construct typed paths as soon as you receive user input, environment values, or config data. Do not carry raw strings through the rest of the application if the path has already been validated.
Keep wrappers small
Avoid adding too much behavior to the wrapper types. Their job is to preserve invariants, not to become a full filesystem abstraction.
Prefer borrowing for read-only APIs
If a function only needs to inspect a path, accept &Path or AsRef<Path>. Use typed wrappers when the function depends on a specific invariant.
Be careful with time-of-check/time-of-use
A path can change between validation and use. For example, a file might be deleted after ExistingFile::new() succeeds. Typed wrappers improve correctness, but they do not eliminate filesystem races. If your application is security-sensitive, you may need stronger OS-level guarantees.
Consider canonicalization when needed
If you need to resolve . and .. segments or symlinks, use std::fs::canonicalize. Be aware that canonicalization requires the path to exist and may change semantics across platforms.
Extending the API
You can grow this pattern in a controlled way. Common extensions include:
NonEmptyPathfor paths that are not empty stringsRelativePathfor paths that must not be absoluteExistingSymlinkif your application distinguishes symlinksReadableFileandWritableDirfor permission-aware workflows
A useful extension is a relative path type:
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RelativePath(PathBuf);
impl RelativePath {
pub fn new<P: AsRef<Path>>(path: P) -> Result<Self, PathError> {
let path = path.as_ref();
if path.is_absolute() {
return Err(PathError::NotAbsolute(path.to_path_buf()));
}
Ok(Self(path.to_path_buf()))
}
pub fn as_path(&self) -> &Path {
&self.0
}
}This is helpful for config values that are meant to be resolved against a base directory.
When not to use typed path wrappers
Typed wrappers are not always the best choice. Keep using plain Path or PathBuf when:
- the code is a thin pass-through to another API
- the path is only used for logging or display
- validation would duplicate checks already performed elsewhere
- the abstraction would add more ceremony than value
The goal is clarity, not type inflation.
Summary
A typed file path API gives Rust programs a practical middle ground between raw filesystem paths and heavyweight abstractions. By validating invariants at construction time, you can make function signatures more expressive, reduce repeated checks, and catch mistakes earlier.
The pattern is especially effective for application boundaries: CLI input, configuration, file import/export, and directory traversal. Start with a few focused wrappers, keep the API small, and expand only when a new invariant appears repeatedly in your codebase.
