logo
Kurashizu Blogwhere ideas flow
  • Home
  • News
  • Blog
  • About
  • Home
  • News
  • Blog
  • About

© 2026 Kurashizu. All rights reserved.

Admin·New Post·Service Status
Powered by Cloudflare·30.10.26
← Back to news

Rusty thoughts on "Parse, don't validate"

Original article
eli.thegreenplace.net|48 points|19 comments|by ingve|Sep 27, 2026

Reflections on "Parse, don't validate" in Rust

Eli's Logo

Like many in the developer community, I found Alexis King's essay, "Parse, don't validate," to be quite compelling. It provides a formal name for a design pattern that many of us use intuitively but rarely articulate.

While the original piece focuses on Haskell, this post explores how the "Parse, don't validate" philosophy manifests within the Rust ecosystem. My goal was to identify practical applications of this pattern in the Rust standard library and other prominent open-source projects.

The Core Concept: Instead of checking if a value meets a criteria (validation), transform the value into a new type that guarantees that criteria is met (parsing).


The Problem: The "Pinky Promise" Invariant

Let's look at the standard Vec<T>. Its .first() method returns an Option<&T> because a vector might be empty.

Imagine a scenario where we fetch configuration paths from an environment variable, but our application requires at least one path to function.

use anyhow::{Result, ensure};
use std::env;
use std::path::PathBuf;

fn get_configuration_directories() -> Result<Vec<PathBuf>> {
    let value = env::var("CONFIG_DIRS")?;
    let directories: Vec<PathBuf> = value.split(',').map(PathBuf::from).collect();
    
    // VALIDATION STEP
    ensure!(!directories.is_empty(), "CONFIG_DIRS cannot be empty");
    
    Ok(directories)
}

Now, consider the consumption of this function:

fn main() -> Result<()> {
    let config_dirs = get_configuration_directories()?;

    // We KNOW it's not empty, but the type system doesn't.
    if let Some(cache_dir) = config_dirs.first() {
        initialize_cache(cache_dir);
    } else {
        unreachable!("We already checked that CONFIG_DIRS is non-empty!");
    }
    Ok(())
}

Why this is suboptimal:

  • Clarity: The code is cluttered with redundant checks.
  • Performance: We are performing checks that we know will pass.
  • Fragility: If the logic in get_configuration_directories changes, the unreachable! call becomes a safe bet ticking time bomb.

The fundamental issue is that Vec is a type that can be empty. We are relying on a "pinky promise" that the invariant holds, rather than encoding it into the type system.


The Solution: Type-Driven Invariants

Instead of returning a Vec, we can use a type that physically cannot represent an empty state. The nonempty crate provides a perfect example:

pub struct NonEmpty<T> {
    pub head: T,
    pub tail: Vec<T>,
}

Because NonEmpty requires a head element, it is impossible to instantiate an empty one. Consequently, its .first() method doesn't need to return an Option.

Implementation Details

The crate ensures NonEmpty feels like a standard Vec by implementing common traits and providing conversion logic:

impl<T> NonEmpty<T> {
    pub const fn new(e: T) -> Self {
        Self::singleton(e)
    }

    pub const fn singleton(head: T) -> Self {
        NonEmpty { head, tail: Vec::new() }
    }

    pub const fn first(&self) -> &T {
        &self.head
    }
}

pub fn from_vec<T>(mut vec: Vec<T>) -> Option<NonEmpty<T>> {
    if vec.is_empty() {
        None
    } else {
        let head = vec.remove(0);
        Some(NonEmpty { head, tail: vec })
    }
}

The Refactored Workflow

Now, let's update our configuration function:

fn get_configuration_directories() -> Result<NonEmpty<PathBuf>> {
    let value = env::var("CONFIG_DIRS")?;
    let directories: Vec<PathBuf> = value.split(',').map(PathBuf::from).collect();
    
    // PARSING STEP: Transform Vec -> NonEmpty
    let Some(non_empty_dirs) = NonEmpty::from_vec(directories) else {
        bail!("CONFIG_DIRS cannot be empty");
    };
    
    Ok(non_empty_dirs)
}

fn main() -> Result<()> {
    let config_dirs = get_configuration_directories()?;
    // No Option handling needed!
    initialize_cache(config_dirs.first()); 
    Ok(())
}

Visualizing the Transformation

The shift from validation to parsing can be viewed as a state transition:

Comparison Summary

FeatureValidation ApproachParsing Approach
Return TypeGeneric (e.g., Vec<T>)Specific (e.g., NonEmpty<T>)
GuaranteeTemporal (checked at one point)Structural (guaranteed by type)
Client CodeMust handle "impossible" errorsClean, direct access
LogicCheck(x)→Bool\text{Check}(x) \rightarrow \text{Bool}Check(x)→BoolTraw→Result<Trefined,E>T_{raw} \rightarrow \text{Result}<T_{refined}, E>Traw​→Result<Trefined​,E>

Real-World Applications

1. Core POSIX Utilities

In the Rust rewrite of POSIX tools, NonEmpty is used to define shell pipelines. A Pipeline must contain at least one command to be valid:

pub struct Pipeline {
    pub commands: NonEmpty<Command>,
    pub negate_status: bool,
}

The parser ensures that a Pipeline is only constructed if the AST contains commands. Once created, the rest of the program can access the first command without worrying about None.

2. Rust-Analyzer and Type Refinement

rust-analyzer employs a technique called gradual parsing. Instead of using a generic path type, they use AbsPathBuf to signify a path that is guaranteed to be absolute.

pub struct AbsPathBuf(Utf8PathBuf);

impl TryFrom<Utf8PathBuf> for AbsPathBuf {
    type Error = Utf8PathBuf;
    fn try_from(path_buf: Utf8PathBuf) -> Result<Self, Self::Error> {
        if !path_buf.is_absolute() {
            return Err(path_buf);
        }
        Ok(AbsPathBuf(path_buf))
    }
}

This is a mathematical refinement of the type: Utf8PathBuf→is_absoluteAbsPathBuf\text{Utf8PathBuf} \xrightarrow{\text{is\_absolute}} \text{AbsPathBuf}Utf8PathBufis_absolute​AbsPathBuf

By using Utf8PathBuf (from the camino crate), they also solve the issue where standard Rust paths are not guaranteed to be valid UTF-8.


Final Checklist for Implementation

If you want to apply this pattern, ask yourself:

  • Am I checking the same invariant in multiple places?
  • Am I using unreachable!() or .unwrap() because "I already checked this"?
  • Can I create a new type that makes the invalid state unrepresentable?
  • Can I move the validation logic into a TryFrom or a custom constructor?