Parse, Don't Validate, in Rust

Validation throws away what it learned. Parsing returns it. In Rust, that moves invariants into the type system, where both developers and coding agents have to respect them.

Table of Contents

OpenAI published a post about harness engineering , describing how they run a codebase written almost entirely by Codex. One rule stood out:

We require Codex to parse data shapes at the boundary, but are not prescriptive on how that happens (the model seems to like Zod, but we didn’t specify that specific library).

“Parse data shapes at the boundary” links to Alexis King’s Parse, don’t validate . A 2019 idea from Haskell turns out to be one of the rules they rely on to keep an agent-written codebase under control.

The whole idea is what you get back after the check:

Validation returns an empty type and the facts it checked are discarded. Parsing returns a Deploy type that holds those facts.
Figure 1. Validation throws away what it learned. Parsing returns it.

Here’s what that looks like in Rust. Java gets the first example, because that’s where I learned to validate.

Validation in Java

If you’ve built Spring Boot applications, this should look familiar:

public record DeployRequest(
    @Pattern(regexp = "[a-z0-9-]{3,30}") String service,
    @Pattern(regexp = "dev|staging|production") String environment,
    @Positive int replicas
) {}

@PostMapping("/deploys")
public ResponseEntity<?> deploy(@Valid @RequestBody DeployRequest req) {
    deployService.submit(req);
    return ResponseEntity.accepted().build();
}

There’s nothing wrong with this code. The important part is that validation does not change the type.

req.service() returns a String before @Valid runs and a String after. A request that passed validation has the same type as one that has not been validated, so nothing in the value itself tells the compiler which one it has.

That tradeoff makes sense in Java, where introducing wrapper types adds more object types and framework friction. In Rust, newtypes are much cheaper, so you can keep the result of validation in the type instead.

Validation vs parsing

Compare these two signatures:

fn validate(req: &DeployRequest) -> Result<(), DeployError>

fn parse(req: DeployRequest) -> Result<Deploy, DeployError>

The return types differ on success, and that difference is the whole idea. Validation answers the question and then throws away what it learned. Parsing answers the same question and returns a value whose type represents data that passed those checks. Downstream code can require that parsed type instead of accepting the raw request.

Rust makes that loss explicit in the signature. () is the type with exactly one value, so it carries no information. A function returning Result<(), E> has nothing to return on success. If the function’s job is to check a value and it learns something useful about it, returning () throws that information away.

There is another Rust-specific difference in those signatures: validate borrows the request, while parse takes ownership of it. A successful parse consumes the raw DeployRequest and returns a Deploy, so the caller can’t accidentally keep using the original request afterward.

The standard library already does this

The standard library often keeps useful guarantees in the type it returns. Nobody writes is_utf8(&[u8]) -> bool, because std::str::from_utf8 returns a &str instead of a yes/no answer. &str is a &[u8] plus a proof that the bytes are valid UTF-8, so any function that takes &str can rely on that guarantee without checking the bytes again.

NonZeroU32 does the same thing for numbers. It’s a u32 plus a guarantee that it isn’t zero, and NonZeroU32::new checks that constraint when you construct one:

let workers = NonZeroU32::new(count)?;
let per_worker = total / workers;  // no division-by-zero case to handle

A function that takes NonZeroU32 no longer has to handle zero as an input.

The same code in Rust

Here’s the Java example in Rust:

pub struct DeployRequest {
    pub service: String,
    pub environment: String,
    pub replicas: u32,
}

pub fn validate(req: &DeployRequest) -> Result<(), DeployError> {
    if !is_service_name(&req.service) {
        return Err(DeployError::BadServiceName);
    }

    if !is_environment(&req.environment) {
        return Err(DeployError::UnknownEnvironment);
    }

    if req.replicas == 0 {
        return Err(DeployError::ZeroReplicas);
    }

    Ok(())
}

The function now knows that replicas is not zero, but the return type has nowhere to keep that information.

The problem appears when another function needs to rely on that check:

pub fn memory_per_replica_mb(req: &DeployRequest, total_mb: u32) -> u32 {
    total_mb / req.replicas
}

Nothing in the signature says that validate was called earlier, so this test compiles, type-checks, and panics:

#[test]
#[should_panic(expected = "attempt to divide by zero")]
fn nothing_stops_you_from_skipping_the_check() {
    let r = req("checkout-api", "production", 0);
    let _ = memory_per_replica_mb(&r, 1024);
}

Refining the type

The fix for DeployRequest is to give each field its own type, keep the inner value private, and only allow construction through a function that can fail.

The important boundary is the module. Code outside the module can’t construct these types directly, so it has to use their parse functions or constructors. That means each check only needs to be correct in one place.

All three types live in the same module and use the same error type:

pub mod domain {
    use std::num::NonZeroU32;

    #[derive(Debug, PartialEq, Eq)]
    pub enum ParseError {
        BadServiceName,
        UnknownEnvironment,
        ZeroReplicas,
    }

    // domain types below
}

Start with the service name:

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ServiceName(String);

impl ServiceName {
    pub fn parse(raw: &str) -> Result<Self, ParseError> {
        let canonical = raw.trim().to_ascii_lowercase();

        let len_ok = (3..=30).contains(&canonical.len());
        let chars_ok = canonical.chars().all(|c| {
            c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'
        });

        if len_ok && chars_ok {
            Ok(Self(canonical))
        } else {
            Err(ParseError::BadServiceName)
        }
    }

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

The field is private, so code outside the module can’t create a ServiceName directly. It has to call ServiceName::parse.

The parse also canonicalises before checking. It trims and lowercases the input, validates that value, and stores the same value. If you validate first and normalise later, you may end up storing something different from what you actually checked.

as_str() makes the conversion back to a plain string explicit. I prefer that over Deref<Target = str>, which would let a ServiceName behave like a str implicitly in many places.

The environment uses the same pattern, but the valid values come from a fixed set:

const ENVIRONMENTS: [&str; 3] = ["dev", "staging", "production"];

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Environment(&'static str);

impl Environment {
    pub fn parse(raw: &str) -> Result<Self, ParseError> {
        let canonical = raw.trim().to_ascii_lowercase();

        ENVIRONMENTS
            .into_iter()
            .find(|name| *name == canonical)
            .map(Environment)
            .ok_or(ParseError::UnknownEnvironment)
    }

    pub fn name(self) -> &'static str {
        self.0
    }
}

The type is named after what the value means, not what it is made of. Environment isn’t “a string someone typed”. It’s an environment this system deploys to. Add another environment later and ENVIRONMENTS changes, while the meaning of Environment stays the same.

The replica count is slightly different. serde has already parsed it into a u32, so Replicas::new only has to check the remaining rule: the value must not be zero. For that, it can reuse a type from std:

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Replicas(NonZeroU32);

impl Replicas {
    pub fn new(raw: u32) -> Result<Self, ParseError> {
        let count = NonZeroU32::new(raw).ok_or(ParseError::ZeroReplicas)?;
        Ok(Self(count))
    }

    pub fn get(self) -> NonZeroU32 {
        self.0
    }
}

Replicas wraps NonZeroU32, so zero cannot be represented by the type. get returns that NonZeroU32 rather than a plain u32, so a function that receives it does not have to check for zero again.

These types should not derive Default unless the default value is valid. An empty String, for example, is not a valid ServiceName.

Now put those parsed fields together:

pub struct Deploy {
    pub service: ServiceName,
    pub environment: Environment,
    pub replicas: Replicas,
}

impl TryFrom<DeployRequest> for Deploy {
    type Error = ParseError;

    fn try_from(req: DeployRequest) -> Result<Self, Self::Error> {
        Ok(Self {
            service: ServiceName::parse(&req.service)?,
            environment: Environment::parse(&req.environment)?,
            replicas: Replicas::new(req.replicas)?,
        })
    }
}

Deploy checks the same three things as validate. The difference is that, on success, it returns a value that keeps what those checks learned in its types.

Before parsing:

pub fn memory_per_replica_mb(req: &DeployRequest, total_mb: u32) -> u32 {
    total_mb / req.replicas
}

After parsing:

pub fn memory_per_replica_mb(deploy: &Deploy, total_mb: u32) -> u32 {
    total_mb / deploy.replicas.get()
}

The function no longer depends on validation having happened somewhere earlier. Replicas already guarantees that the divisor is non-zero.

try_from also consumes the request. After the conversion, the raw DeployRequest has moved out of scope, so you can’t accidentally keep using the unchecked form. Neither Haskell nor Java gives you that by default, because in both the original value stays in scope.

Outside the domain module, constructing an invalid ServiceName is a compile error rather than a review comment:

let sneaky = ServiceName("Not A Service".to_string());
// error[E0603]: tuple struct constructor `ServiceName` is private

Refining is one way to put knowledge into a type. It stops invalid values from being constructed. Another is restructuring, which stops invalid combinations from being represented at all.

Removing invalid states

Here’s what you get by mapping a database row straight into a struct:

pub struct DeploymentRow {
    pub is_running: bool,
    pub is_finished: bool,
    pub finished_at_ms: Option<u64>,
    pub failure_reason: Option<String>,
}

4 fields, 16 combinations, and only a few that describe a real deployment. A deployment can be marked as both running and finished, or finished without a timestamp, and the type system accepts it. Any code that reads this struct then has to deal with those invalid combinations too.

An enum can encode only the states the system actually allows:

pub enum Deployment {
    Queued,
    RollingOut { started_at_ms: u64 },
    Live { since_ms: u64 },
    RolledBack { reason: String },
}

4 states, 4 variants, each carrying exactly the data that state needs. A queued deployment has no finished_at_ms, so code can’t try to use one. There are no invalid combinations to handle, and adding a fifth state later makes every incomplete match fail to compile.

This also removes the duplication between is_finished and finished_at_ms. Both represented the same fact, but they could disagree.

The standard library uses this idea too. In Java, a comparator returns an integer, even though only negative, zero, and positive matter. Rust returns Ordering instead, with exactly three variants: Less, Equal, and Greater.

Put the rule in the type

Both newtypes and enums move rules out of control flow and into the type itself.

With validation, a function can still accept the raw type after the check has run:

fn submit(req: &DeployRequest)

Nothing in that signature says whether req passed validation.

With parsed domain types, the requirement becomes part of the function signature:

fn submit(deploy: &Deploy)

Now callers have to provide a Deploy, and the function can rely on the guarantees that come with it.

That is the practical difference. With validation, the caller has to make sure the check happened first. With parsing, the function can require a type that could only be produced after those checks passed.

Shotgun parsing

So far, the benefit has been about types. The next problem is what happens when code starts changing state before validation is finished.

Shotgun parsing is when input checks are spread through the same code that acts on the input. The program starts doing work before it has finished deciding whether the input is valid.

The name comes from the 2016 LangSec paper The Seven Turrets of Babel : instead of parsing the input once at the start, checks are added wherever they happen to be needed.

Here’s a deploy handler that does it:

pub fn deploy_shotgun(
    world: &mut World,
    req: &DeployRequest,
) -> Result<(), DeployError> {

    if !is_service_name(&req.service) {
        return Err(DeployError::BadServiceName);
    }

    // effect 1
    world.deployments.push(req.service.clone());

    if !is_environment(&req.environment) {
        return Err(DeployError::UnknownEnvironment);
    }

    // effect 2
    let route = format!("{} -> {}", req.environment, req.service);
    world.traffic.push(route);

    if req.replicas == 0 {
        // too late
        return Err(DeployError::ZeroReplicas);
    }

    let entry = format!("deployed {} to {}", req.service, req.environment);
    world.audit.push(entry);
    Ok(())
}

Send it a valid service name, a real environment, and a replica count of 0:

$ cargo run
shotgun     -> rejected with ZeroReplicas
  deployments : ["checkout-api"]
  traffic     : ["production -> checkout-api"]
  audit       : []

parse-first -> rejected with ZeroReplicas
  deployments : []
  traffic     : []
  audit       : []

The deploy was rejected, but the program has already changed its state. The deployment was recorded and traffic was updated. The audit log is empty too, because the audit write came after the failing check, so the one record that would tell you what happened is the one record you don’t have.

The paper states the consequence directly:

Shotgun parsing necessarily deprives the program of the ability to reject invalid input instead of processing it. Late-discovered errors in an input stream will result in some portion of invalid input having been processed, with the consequence that program state is difficult to accurately predict.

The problem isn’t that any individual check is wrong. You can fix this example by moving every check before the first side effect, but once validation and processing are mixed together, later changes can easily break that ordering again.

The safer approach is to separate the two steps: first parse the input, then act on the parsed value:

pub fn deploy(world: &mut World, deploy: &Deploy) {
    world.deployments.push(deploy.service.as_str().to_owned());
    world.traffic.push(format!(
        "{} -> {}",
        deploy.environment.name(),
        deploy.service.as_str()
    ));
    world.audit.push(format!(
        "deployed {} to {}",
        deploy.service.as_str(),
        deploy.environment.name()
    ));
}

Then at the boundary:

let parsed = Deploy::try_from(req)?;
deploy(&mut world, &parsed);

The operation that changes state takes a Deploy, not a DeployRequest. Raw input therefore has to be parsed before it can be passed to that operation. The compiler can’t stop unrelated code from changing World, but it can make this deployment path require parsed input.

So “check before you act” becomes something the compiler enforces instead of a rule developers have to remember.

Why agents drift into shotgun parsing

Nobody sits down to write a shotgun parser. It happens gradually. A new requirement arrives, and its check gets added where the relevant data is already available, often somewhere in the middle of the processing. Over time, a codebase that relies on separate validation checks can drift into this pattern.

Coding agents often make changes where the relevant data is already available instead of restructuring the whole function. If you ask for “production deploys need an approver,” an agent may find the branch where the environment is checked and add another if there. That change makes sense on its own, but repeated changes like this are how shotgun parsing grows.

The way this happens hasn’t changed. The speed has. Two years of drift can now happen in an afternoon.

The same logic explains why types beat instructions here. A rule in your AGENTS.md costs context on every turn, competes with everything else in the file, and can’t be enforced. A type in the signature is in context whenever the agent reads the function, needs no reminding, and fails the build when violated.

Types can enforce structure, but they can’t tell whether the structure is a good one. An agent can still make a poor design compile, so you still need to read the diff. The difference is that you no longer have to check whether validation happened before the code changed state. You can focus on whether the types themselves represent the right rules.

Parsing at the boundary

The raw request type only needs to describe the input format. serde can then convert it into Deploy before the rest of the program sees it.

The TryFrom implementation from earlier already does the conversion. serde’s try_from attribute tells it to deserialise a DeployRequest first and then call Deploy::try_from:

#[derive(Deserialize)]
pub struct DeployRequest {
    pub service: String,
    pub environment: String,
    pub replicas: u32,
}

#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize)]
#[serde(try_from = "DeployRequest")]
pub struct Deploy { /* unchanged */ }

DeployRequest derives Deserialize and mirrors the JSON, nothing more. Deploy is the domain type. serde first builds a DeployRequest, passes it to your TryFrom implementation, and returns either a Deploy or your own error. ParseError needs a Display impl for that last part, since serde reports the failure as a string:

let json = r#"{
    "service": "checkout-api",
    "environment": "production",
    "replicas": 0
}"#;
let err = serde_json::from_str::<Deploy>(json).unwrap_err();
// replicas must be at least 1

After from_str succeeds, the rest of the program only sees Deploy, not DeployRequest. That keeps the raw input type at the boundary and prevents handlers from accepting unparsed requests by accident.

That error message matters for agents too. If parsing fails during a build or test, the agent may see the error directly. A message like replicas must be at least 1 tells it what constraint was violated and what needs to change. An error with no useful message gives it much less to work with.

The derive that skips your constructor

This trap is Rust-specific, and it’s easy to miss.

#[derive(Debug, Deserialize, PartialEq)]
pub struct Replicas(u32); // private field

impl Replicas {
    pub fn new(raw: u32) -> Result<Self, &'static str> {
        if raw == 0 {
            return Err("replicas must be at least 1");
        }
        Ok(Self(raw))
    }

    pub fn get(&self) -> u32 {
        self.0
    }
}

It looks safe, but it isn’t. The generated Deserialize implementation can construct Replicas directly even though its field is private. That means it can bypass the constructor entirely:

#[test]
fn derive_bypasses_constructor() {
    assert!(Replicas::new(0).is_err());

    // ...and yet deserialisation accepts zero.
    let smuggled: Replicas = serde_json::from_str("0").unwrap();
    assert_eq!(smuggled.get(), 0);
}

serde has now created a Replicas(0) even though the constructor rejects zero. Private fields prevent code in other modules from constructing the type directly, but generated code in the same module can still do it. The fix is to tell serde to deserialise through TryFrom instead:

Note what makes this possible. The inner type is u32, which can hold zero, so the rule lives only in the constructor. The Replicas(NonZeroU32) from earlier has no such hole, because serde deserialises the inner NonZeroU32 and that rejects zero on its own. The further you push a rule into the types, the less there is left to bypass.

#[derive(Debug, Deserialize, PartialEq)]
#[serde(try_from = "u32")]
pub struct Replicas(u32);

impl TryFrom<u32> for Replicas {
    type Error = &'static str;

    fn try_from(raw: u32) -> Result<Self, Self::Error> {
        Self::new(raw)
    }
}

This is easy to miss in agent-written code. #[derive(Deserialize)] is the usual way to make a newtype deserialisable, so an agent may add it without noticing that it bypasses the constructor. In this case, invalid input never reaches Replicas::new, so the replicas must be at least 1 error is never produced.

This is exactly the kind of rule I would enforce rather than leave in AGENTS.md. In a larger codebase, domain newtypes should not be allowed to derive Deserialize directly. A CI check can reject that pattern unless deserialisation goes through TryFrom, and a test can verify that invalid values fail through both the constructor and serde.

Tradeoffs and limits

Using domain types adds more structs and conversion code. That is usually worth it for values that cross module boundaries or can cause real damage when they are wrong. It is probably not worth doing for every field in a small program.

Using ? reports the first error and stops, but that is just one implementation choice. If you need to show several validation errors at once, you can collect them first and only construct Deploy when all checks pass.

And Result<(), E> is not always a problem. Functions like fs::write return () because they do not learn anything new about an input value. The problem is specific to validation functions that check a value and then throw away what they learned.

Where to start

Start with places where the code already hints that validation happened earlier:

  1. Search for comments, assertions, or panic messages like “validated earlier”, “checked above”, or “cannot fail here”. They often mark a fact the type does not record.
  2. Find functions that return Result<(), E> and only inspect their input. Ask what the function knows after it succeeds, and whether it could return a type that represents that result instead.
  3. Look at functions that accept raw input and also change state. If they can return an error after changing something, move all parsing before the first change.
  4. Look for structs where fields can contradict each other. An enum may represent the valid states more directly.
  5. Check domain newtypes for #[derive(Deserialize)] and #[derive(Default)]. Deserialize may bypass the parser, and Default is unsafe when the default inner value does not satisfy the type’s rules.
  6. Consider #[serde(deny_unknown_fields)] for request and config types. serde ignores unknown fields by default, so a typo like “replcias” can otherwise be silently ignored.

Don’t try to change everything at once. Moving one value behind a parsed domain type at one boundary is already useful.

For coding agents, keep the rules short and mechanical:

AGENTS.md
- Parse external input into domain types at the boundary. Handlers
  take parsed types, not raw request or config structs.
- A function whose only job is checking input should return what it learned,
  not `Result<(), E>`.
- Domain types keep their fields private and are created through functions
  that can fail.
- Finish parsing before changing state.

I would enforce the serde rule separately in CI. Domain newtypes should not derive Deserialize directly unless deserialisation is explicitly routed through TryFrom.

Summary

Parsing forces you to decide what your types mean, and that’s real work. It also won’t stop an agent from making a poor design compile. What it gives you is a guarantee the compiler checks at every call site, including ones that haven’t been written yet.

That is why this matters more when agents are writing more of the code. When code arrives faster than anyone can review it, the best invariants are the ones reviewers don’t have to check manually.

Also available via RSS, Telegram, or X (@rdiachenko)
Questions or ideas? Email me

Explore More Posts