Skip to main content
Background Image
  1. Blog/

Refined Types in Rust: Parse, Don't Validate

Author
Glen Baker
Building tech startups and open source tooling
Table of Contents

The String Problem
#

How many times have you seen function signatures like this?

fn send_email(to: String, subject: String, body: String) -> Result<(), Error>;

fn create_user(email: String, username: String, password: String) -> Result<User, Error>;

fn transfer_money(from_iban: String, to_iban: String, amount: f64) -> Result<(), Error>;

Every String in these signatures is misleading. They don’t accept any string—they accept specific formats. Pass "not-an-email" to send_email and it will fail. Pass "hello" as an IBAN and your money transfer crashes.

The signature promises one thing; the implementation demands another.

Validation Everywhere
#

The typical fix is validation at every entry point:

fn send_email(to: String, subject: String, body: String) -> Result<(), Error> {
    if !is_valid_email(&to) {
        return Err(Error::InvalidEmail(to));
    }
    // ... send email
}

fn send_welcome_email(email: String) -> Result<(), Error> {
    if !is_valid_email(&email) {
        return Err(Error::InvalidEmail(email));
    }
    // ... send welcome email
}

fn send_password_reset(email: String) -> Result<(), Error> {
    if !is_valid_email(&email) {
        return Err(Error::InvalidEmail(email));
    }
    // ... send reset email
}

Three functions, three validation checks, same validation logic copy-pasted. And what happens when you add a fourth function? Fifth? What happens when the validation logic needs to change?

You might say: “Just validate at the API boundary.” But then internal code passes raw strings around, and you’re trusting that somewhere upstream, someone validated. The compiler can’t verify that trust.

Parse, Don’t Validate
#

Validation returns a boolean, parsing returns a value.

When you validate, you check if data is correct and then continue using the same data. The knowledge that it’s valid exists only in your head (and maybe in a comment).

When you parse, you transform unstructured data into structured data. The knowledge that it’s valid is encoded in the type.

// Validation: returns bool, you keep the String
fn is_valid_email(s: &str) -> bool { ... }

// Parsing: returns Email, proof of validity in the type
fn parse_email(s: String) -> Result<Email, EmailError> { ... }

Once you have an Email, you know it’s valid. The type proves it.

Refined Types
#

A refined type wraps a base type with a predicate that must be satisfied:

// Conceptually: Email = String where is_valid_email(s) == true
struct Email(String);

impl Email {
    pub fn new(s: String) -> Result<Email, EmailError> {
        if is_valid_email(&s) {
            Ok(Email(s))
        } else {
            Err(EmailError::InvalidFormat)
        }
    }

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

The constructor is the only way to create an Email. It validates on construction. After that, you have proof of validity that the compiler enforces.

Now the function signatures accurately reflect their requirements:

fn send_email(to: Email, subject: String, body: String) -> Result<(), Error>;

fn create_user(email: Email, username: Username, password: Password) -> Result<User, Error>;

fn transfer_money(from: Iban, to: Iban, amount: Money) -> Result<(), Error>;

Try passing a raw String to send_email and the compiler stops you. The type system prevents invalid data from entering your domain.

The Practical Benefits
#

1. Validation Happens Once
#

// At the API boundary
let email = Email::new(request.email)?;

// Now pass Email through your entire system
// No re-validation needed anywhere
send_welcome_email(email.clone());
subscribe_to_newsletter(&email);
log_user_registration(&email);

Validate at the boundary, trust the type everywhere else.

2. Invalid States Become Unrepresentable
#

struct User {
    email: Email,      // Can never be invalid
    username: Username, // Can never be invalid
    age: Age,          // Can never be negative
}

You can’t construct a User with an invalid email. The type system prevents it. No defensive checks needed inside your domain logic.

3. Function Signatures Document Requirements
#

// Before: What format does this expect?
fn process_phone(phone: String) -> Result<(), Error>;

// After: Self-documenting
fn process_phone(phone: PhoneNumber) -> Result<(), Error>;

The type is the documentation. No comments needed, no runtime surprises.

4. Refactoring Becomes Safer
#

Need to change email validation rules? Change the Email::new constructor. Every place that creates emails is affected. The compiler shows you everywhere an Email is used.

impl Email {
    pub fn new(s: String) -> Result<Email, EmailError> {
        // Add new rule: reject emails from blocked domains
        if BLOCKED_DOMAINS.contains(&domain_of(&s)) {
            return Err(EmailError::BlockedDomain);
        }
        // ... rest of validation
    }
}

All call sites that construct Email now enforce the new rule. No grep required.

Building Refined Types with Stillwater
#

Stillwater provides a Refined<T, P> type that makes building refined types ergonomic:

use stillwater::refined::{Predicate, Refined};

// Define the predicate
struct IsValidEmail;

impl Predicate<String> for IsValidEmail {
    fn test(value: &String) -> bool {
        value.contains('@') && value.len() >= 5
    }

    fn error_message() -> String {
        "invalid email format, expected local@domain".to_string()
    }
}

// The refined type
type Email = Refined<String, IsValidEmail>;

// Usage
let email = Email::new("[email protected]".to_string())?;
println!("{}", email.value());  // Access inner value

The Refined type handles the boilerplate: constructor validation, inner value access, error messages, Debug/Display implementations.

Domain-Specific Types with Stilltypes
#

Stilltypes provides production-ready refined types for common domains:

use stilltypes::prelude::*;

// Email (RFC 5321 compliant)
let email = Email::new("[email protected]".to_string())?;

// URL with scheme enforcement
let secure_url = SecureUrl::new("https://api.example.com".to_string())?;
let insecure = SecureUrl::new("http://api.example.com".to_string());
assert!(insecure.is_err());  // HTTP rejected

// Phone numbers (E.164)
let phone = PhoneNumber::new("+1 415 555 1234".to_string())?;
assert_eq!(phone.to_e164(), "+14155551234");

// Financial identifiers
let iban = Iban::new("DE89370400440532013000".to_string())?;
assert_eq!(iban.country_code(), "DE");

// Network types
let port = Port::new(443)?;
assert!(port.is_privileged());

These implementations are backed by RFC-compliant validation libraries. PhoneNumber uses Google’s libphonenumber. Iban uses proper checksum validation. Email follows RFC 5321.

Composition: Multiple Constraints
#

Refined types compose. Need a URL that’s both valid and uses HTTPS?

use stillwater::refined::{And, Predicate, Refined};

// Individual predicates
struct IsValidUrl;
struct IsHttps;

impl Predicate<String> for IsValidUrl { ... }
impl Predicate<String> for IsHttps { ... }

// Combined predicate
type SecureUrl = Refined<String, And<IsValidUrl, IsHttps>>;

The And combinator requires both predicates to pass. There’s also Or and Not.

Serde Integration
#

With serde support, refined types validate during deserialization:

use stilltypes::prelude::*;
use serde::Deserialize;

#[derive(Deserialize)]
struct CreateUserRequest {
    email: Email,
    website: Option<SecureUrl>,
    phone: PhoneNumber,
}

// Invalid JSON fails to deserialize
let result: Result<CreateUserRequest, _> = serde_json::from_str(json);

Your API automatically rejects invalid data at the parsing layer. No validation code in your handler.

The Boundary Pattern
#

Where should you create refined types? At the boundary between untrusted and trusted code:

// API Handler - THE BOUNDARY
async fn create_user(Json(input): Json<RawUserInput>) -> Result<Json<User>, ApiError> {
    // Parse raw input into refined types
    let email = Email::new(input.email)
        .map_err(|e| ApiError::Validation(e.to_string()))?;
    let username = Username::new(input.username)
        .map_err(|e| ApiError::Validation(e.to_string()))?;

    // From here on, work with validated types
    let user = user_service.create(email, username).await?;

    Ok(Json(user))
}

// Service layer - TRUSTS THE TYPES
impl UserService {
    // No validation needed - types guarantee validity
    async fn create(&self, email: Email, username: Username) -> Result<User, ServiceError> {
        self.repo.insert(User { email, username }).await
    }
}

// Repository layer - TRUSTS THE TYPES
impl UserRepository {
    async fn insert(&self, user: User) -> Result<User, DbError> {
        // email and username are guaranteed valid
        sqlx::query("INSERT INTO users (email, username) VALUES ($1, $2)")
            .bind(user.email.value())
            .bind(user.username.value())
            .execute(&self.pool)
            .await?;
        Ok(user)
    }
}

Validation happens once at the HTTP handler. Service and repository layers receive pre-validated data. No defensive checks, no redundant validation, no possibility of invalid data sneaking through.

Error Accumulation
#

What about forms with multiple fields? You want all errors, not just the first. Combine refined types with Stillwater’s Validation:

use stilltypes::prelude::*;
use stillwater::Validation;

fn validate_form(input: FormInput) -> Validation<ValidatedForm, Vec<String>> {
    Validation::all((
        Email::new(input.email).map_err(|e| vec![e.to_string()]),
        PhoneNumber::new(input.phone).map_err(|e| vec![e.to_string()]),
        SecureUrl::new(input.website).map_err(|e| vec![e.to_string()]),
    ))
    .map(|(email, phone, website)| ValidatedForm { email, phone, website })
}

// Returns ALL errors, not just the first
match validate_form(input) {
    Validation::Success(form) => process(form),
    Validation::Failure(errors) => {
        // errors: ["invalid email format", "invalid phone number", "URL must use HTTPS"]
    }
}

When Not to Use Refined Types
#

Refined types add indirection. Use them when the benefit outweighs the cost:

Use refined types for:

  • Data that crosses trust boundaries (API input, file parsing, user input)
  • Domain concepts with invariants (email, phone, money, coordinates)
  • Values passed through multiple layers of your application
  • Anything where invalid data would cause bugs

Skip refined types for:

  • Internal data structures with limited scope
  • Values validated by the database layer anyway
  • Simple scripts where the overhead isn’t worth it
  • Prototyping (add them when the design stabilizes)

The Type-Driven Design Mindset
#

Refined types are part of a larger principle: make invalid states unrepresentable.

Instead of runtime checks scattered throughout code, encode constraints in types. The compiler becomes your validation engine. Invalid programs don’t compile.

// Bad: Invalid state representable
struct Order {
    items: Vec<Item>,  // Could be empty!
    status: String,    // Could be anything!
}

// Good: Invalid state unrepresentable
struct Order {
    items: NonEmptyVec<Item>,  // Guaranteed non-empty
    status: OrderStatus,       // Enum with valid states
}

Combined with refined types, you get:

struct Order {
    id: OrderId,              // Validated format
    customer_email: Email,     // Validated email
    shipping: Address,         // Validated address
    items: NonEmptyVec<Item>,  // Non-empty guarantee
    total: Money,             // Non-negative amount
}

Every field carries its own proof of validity. The domain model itself rejects bad data.

Getting Started
#

Add stilltypes to your project:

cargo add stilltypes --features email,url,phone

Pick one stringly-typed field in your API. Replace it with a refined type.

// Before
fn create_user(email: String) -> Result<User, Error> {
    if !is_valid_email(&email) { return Err(...); }
    // ...
}

// After
fn create_user(email: Email) -> Result<User, Error> {
    // Email is already valid, just use it
    // ...
}

Parse at the boundary. Trust the types inside.


Summary
#

ApproachValidation LocationCompiler HelpGuarantees
Raw stringsEvery functionNoneNone
Validation at boundaryEntry pointsNoneTrust-based
Refined typesConstructionFullType-enforced

Refined types move validation from scattered runtime checks to centralized construction. The type system carries proof of validity, eliminating entire categories of bugs.

Parse, don’t validate. Let your types tell the truth.


Stilltypes provides production-ready refined types for Rust. Stillwater provides the underlying Refined<T, P> abstraction and validation utilities.

Related

Stillwater 1.0: Pragmatic Effect Composition and Validation for Rust
Stillwater Validation for Rustaceans: Accumulating Errors Instead of Failing Fast
Compile-Time Resource Tracking in Rust: From Runtime Brackets to Type-Level Safety
Stilltypes - Domain-Specific Refined Types for Rust
Refactoring a God Object Detector That Was Itself a God Object
Premortem vs Figment: Configuration Libraries for Rust Applications
Three Patterns That Made Prodigy's Functional Migration Worth It
Postmortem - Schema Validation for Rust
Stillwater - Pure Core, Imperative Shell for Rust