Alexis King's Parse, don't validate gave a name to an idiom many Rust programmers had already been using without a label. The pattern, originally illustrated in Haskell, shows up repeatedly in the Rust standard library and in well-known projects — which makes it useful to collect the concrete examples.

The starting point is the familiar distinction: a function that only checks a property and returns a boolean or Option leaves the caller to re-check the property later, while a function that returns a new type carrying the property in the type system makes that re-check unnecessary.

Refinement in the standard library

Vec::first returns Option<&T> because a vector may be empty. A function that enforces non-emptiness at its boundary — for instance one that reads configuration directories from an environment variable and rejects an empty list — still returns a Vec, so even a successful result forces the caller through Option handling again:

use anyhow::{Result, ensure};

fn get_configuration_directories() -> Result<Vec<PathBuf>> {
    let value = env::var("CONFIG_DIRS").context("could not read CONFIG_DIRS")?;

    let directories: Vec<PathBuf> = value
        .split(',')
        .map(str::trim)
        .map(PathBuf::from)
        .collect();

    ensure!(!directories.is_empty(), "empty CONFIG_DIRS");
    Ok(directories)
}
fn main() -> Result<()> {
    let config_dirs = get_configuration_directories()?;

    match config_dirs.first() {
        Some(cache_dir) => initialize_cache(cache_dir),
        None => unreachable!("already checked that CONFIG_DIRS is non-empty"),
    }

    Ok(())
}

The invariant lives only in a comment. If get_configuration_directories ever changes, nothing in the type system notices. Clarity, performance, and a subtle time bomb all suffer.

A type that cannot be empty

Such crates as nonempty replace the comment with a type. NonEmpty has no constructor permitting zero elements: new takes one element, and first returns &T directly.

pub struct NonEmpty<T> {
    pub head: T,
    pub tail: Vec<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
}

Traits and conversions bring NonEmpty close to a regular Vec in behavior:

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

Rewriting get_configuration_directories to return NonEmpty moves the check into the constructor:

fn get_configuration_directories() -> Result<NonEmpty<PathBuf>> {
    let value = env::var("CONFIG_DIRS").context("could not read CONFIG_DIRS")?;

    let directories = value
        .split(',')
        .map(str::trim)
        .map(PathBuf::from)
        .collect();

    let Some(directories) = NonEmpty::from_vec(directories) else {
        bail!("CONFIG_DIRS cannot be empty");
    };

    Ok(directories)
}

The call site then reflects the guaranteed invariant — no emptiness test reappears:

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

    initialize_cache(config_dirs.first())?;
    Ok(())
}

That is the parse/validate split: returning a Vec validates; returning NonEmpty parses the vector into a richer type. Parsing here means transforming data from one format to another, where the target format encodes the invariant.

The Rust rewrite of POSIX utilities applies this to shell pipelines:

pub struct Pipeline {
    pub commands: NonEmpty<Command>,
    pub negate_status: bool,
}
fn parse_pipeline(&mut self, alias_table: &AliasTable) -> ParseResult<Option<Pipeline>> {
    // pipeline = "!" command ("|" linebreak command)*
    let negate_status = self.match_alternatives(&[CommandToken::Bang])?.is_some();
    let mut commands = if let Some(command) = self.parse_command(alias_table)? {
        NonEmpty::new(command)
    } else {
        return Ok(None);
    };

    // ...

A Pipeline is returned only when the AST contains commands; otherwise None. Downstream code calls commands.first() without an Option in sight.

Gradual refinement in rust-analyzer

rust-analyzer refines a path once it has been parsed as absolute:

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

Code after that point need not re-check absoluteness. The same holds for UTF-8 validity: AbsPathBuf wraps Utf8PathBuf from the camino crate, not the standard library's PathBuf. Standard paths have no UTF-8 guarantee, and converting them to String is therefore awkward; camino establishes UTF-8 validity on construction, after which conversion is trivial:

fn as_str(&self) -> &str {
  ...
}

The pattern here is cumulative: each construction step adds a guarantee the previous type did not have.

std::path::PathBuf

      |
      |   prove UTF-8
      |
      V

camino::Utf8PathBuf

      |
      |   prove absolute
      |
      V

rust-analyzer's paths::AbsPathBuf

Non-zero integers and deserialization

The generic NonZero types describe unsigned values known to be non-zero. thread::available_parallelism returns one:

pub fn available_parallelism() -> Result<NonZero<usize>>

A successful call yields a NonZero<usize>, so no caller has to consider a parallelism of zero. Rust also defines division with a NonZero denominator as a non-panicking operation. Beyond validation, NonZero enables a niche optimization: zero is not a valid value, so it can serve as the None bit pattern. Option<NonZeroUsize> therefore occupies the same size and alignment as NonZeroUsize — and as usize — avoiding storage Option<usize> would otherwise need.

Serde-driven JSON deserialization applies the same idea without ceremony:

#[derive(Debug, Deserialize)]
struct Config {
    name: String,
    workers: NonZeroUsize,
    mode: Mode,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
enum Mode {
    Fast,
    Safe,
}
let input = r#"
     {
         "name": "compiler",
         "workers": 4,
         "mode": "fast"
     }
 "#;

 let config: Config = serde_json::from_str(input)?;

Several guarantees are enforced as the value lands in the type. Field types are checked, so name cannot arrive as an array; mode must be one of the Mode variants; workers must be non-zero because the field is NonZeroUsize. Once parsing converts mode into a Mode, no further validation follows.

Dynamic languages usually require manual work here. Python's json.loads hands back a dictionary and the caller validates it. Pydantic can approximate the Rust approach, but it is not universally adopted.


[1]Other languages - like Go or Python - have a runtime check that raises some sort of exception or panic when lst[0] is accessed on an empty list or slice.
[2]The project implements its own NonEmpty, without relying on the nonempty crate, but all the points in this post apply.