Projects Blog Music Contact
← All Posts
Tech February 27, 2026

Parse Don't Validate: Type-Driven Design in Rust for Bulletproof Code

By: Evgeny Padezhnov

Illustration for: Parse Don't Validate: Type-Driven Design in Rust for Bulletproof Code

Type-driven design transforms runtime validation into compile-time guarantees. Rust's type system makes this particularly powerful.

The core principle: parse input once into a type that represents valid data. After parsing, the type system ensures data validity throughout the codebase. No repeated validation checks. No defensive programming.

The Problem with Traditional Validation

Most validation approaches follow this pattern:

fn process_email(email: &str) -> Result<(), Error> {
    validate_email(email)?;
    // ... use email
    send_welcome_email(email)?;
    // ... more code
    update_user_email(email)?;
    Ok(())
}

fn send_welcome_email(email: &str) -> Result<(), Error> {
    // Do we validate again? Trust the caller?
    validate_email(email)?;
    // ... send email
}

Common mistake: Functions either validate repeatedly (performance hit) or trust callers (runtime errors).

Parse Don't Validate Pattern

Instead, parse once into a newtype:

#[derive(Debug, Clone)]
pub struct Email(String);

impl Email {
    pub fn parse(s: &str) -> Result<Self, ValidationError> {
        if s.contains('@') && s.len() > 3 {
            Ok(Email(s.to_string()))
        } else {
            Err(ValidationError::InvalidEmail)
        }
    }
    
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

fn process_email(email: Email) -> Result<(), Error> {
    // No validation needed - type guarantees validity
    send_welcome_email(&email)?;
    update_user_email(&email)?;
    Ok(())
}

Key point: Once parsed, Email type guarantees valid email throughout the application.

Advanced Patterns with Phantom Types

For more complex validation, phantom types encode state in the type system:

use std::marker::PhantomData;

struct Validated;
struct Unvalidated;

struct User<State> {
    name: String,
    email: String,
    _state: PhantomData<State>,
}

impl User<Unvalidated> {
    fn new(name: String, email: String) -> Self {
        User {
            name,
            email,
            _state: PhantomData,
        }
    }
    
    fn validate(self) -> Result<User<Validated>, ValidationError> {
        // Validation logic
        if self.email.contains('@') {
            Ok(User {
                name: self.name,
                email: self.email,
                _state: PhantomData,
            })
        } else {
            Err(ValidationError::InvalidEmail)
        }
    }
}

impl User<Validated> {
    fn save_to_database(&self) -> Result<(), DbError> {
        // Only validated users can be saved
        // ...
    }
}

In practice: Compile error if trying to save unvalidated user. No runtime checks needed.

Real-World Example: Configuration Parsing

#[derive(Debug)]
pub struct Config {
    port: Port,
    database_url: DatabaseUrl,
    api_key: ApiKey,
}

#[derive(Debug, Clone)]
pub struct Port(u16);

impl Port {
    pub fn parse(s: &str) -> Result<Self, ParseError> {
        let port: u16 = s.parse()?;
        if port > 1024 {
            Ok(Port(port))
        } else {
            Err(ParseError::PrivilegedPort)
        }
    }
}

#[derive(Debug, Clone)]
pub struct DatabaseUrl(String);

impl DatabaseUrl {
    pub fn parse(s: &str) -> Result<Self, ParseError> {
        if s.starts_with("postgres://") || s.starts_with("mysql://") {
            Ok(DatabaseUrl(s.to_string()))
        } else {
            Err(ParseError::InvalidDatabaseUrl)
        }
    }
}

// Parse configuration once at startup
fn load_config() -> Result<Config, ParseError> {
    Ok(Config {
        port: Port::parse(&env::var("PORT")?)?,
        database_url: DatabaseUrl::parse(&env::var("DATABASE_URL")?)?,
        api_key: ApiKey::parse(&env::var("API_KEY")?)?,
    })
}

Tested in production. Configuration parsing fails fast at startup. Runtime guarantees valid config.

Benefits in Practice

  1. No defensive programming - Types guarantee validity
  2. Clear error boundaries - Parsing happens at system edges
  3. Better performance - Validate once, not repeatedly
  4. Compile-time safety - Invalid operations don't compile

Try it: Replace string parameters with newtypes in one module. Watch runtime errors disappear.

Common Patterns and Anti-Patterns

Do This:

pub struct UserId(u64);
pub struct Temperature(f64);
pub struct Percentage(u8); // 0-100 enforced at parse time

Avoid This:

fn process_data(user_id: u64, temp: f64, percent: u8) {
    if percent > 100 { panic!("Invalid percentage"); }
    // ...
}

In plain terms: Make illegal states unrepresentable. The compiler becomes your validation engine.

Parse input at system boundaries - HTTP requests, configuration files, user input. Inside the application, work with parsed types exclusively. Runtime validation disappears.

The pattern extends beyond simple validation. Use it for state machines, protocol implementations, any domain where certain states should be impossible. Rust's zero-cost abstractions mean no runtime overhead for this safety.

Squeeze AI
  1. Parse input once into a type that represents valid data, then let the type system enforce validity throughout the codebase instead of repeating validation checks in every function.
  2. Phantom types enable encoding validation state into the type system itself, making invalid operations compile errors rather than runtime failures.
  3. Traditional validation approaches force a choice between repeated validation overhead and trusting callers, both leading to either poor performance or undetected errors.

Squeezed by b1key AI